Files
ProxmoxVE-Local/src/app/_components/BackupsTab.tsx
Michel Roegl-Brunner 570eea41b9 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
2025-11-14 15:43:33 +01:00

504 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
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, 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;
backup_name: string;
backup_path: string;
size: bigint | null;
created_at: Date | null;
storage_name: string;
storage_type: string;
discovered_at: Date;
server_id: number;
server_name: string | null;
server_color: string | null;
}
interface ContainerBackups {
container_id: string;
hostname: string;
backups: Backup[];
}
export function BackupsTab() {
const [expandedContainers, setExpandedContainers] = useState<Set<string>>(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<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({
onSuccess: () => {
void refetchBackups();
},
});
// 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: () => {
// 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) {
// 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);
// Keep success message visible - user can dismiss manually
} else {
setRestoreError(result.error || 'Restore failed');
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);
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) {
// Only auto-discover if there are no backups yet
if (!backupsData.backups || backupsData.backups.length === 0) {
handleDiscoverBackups();
}
setHasAutoDiscovered(true);
}
}, [hasAutoDiscovered, isLoading, backupsData]);
const handleDiscoverBackups = () => {
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)) {
newExpanded.delete(containerId);
} else {
newExpanded.add(containerId);
}
setExpandedContainers(newExpanded);
};
const formatFileSize = (bytes: bigint | null): string => {
if (!bytes) return 'Unknown size';
const b = Number(bytes);
if (b === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(b) / Math.log(k));
return `${(b / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
const formatDate = (date: Date | null): string => {
if (!date) return 'Unknown date';
return new Date(date).toLocaleString();
};
const getStorageTypeIcon = (type: string) => {
switch (type) {
case 'pbs':
return <Database className="h-4 w-4" />;
case 'local':
return <HardDrive className="h-4 w-4" />;
default:
return <Server className="h-4 w-4" />;
}
};
const getStorageTypeBadgeVariant = (type: string): 'default' | 'secondary' | 'outline' => {
switch (type) {
case 'pbs':
return 'default';
case 'local':
return 'secondary';
default:
return 'outline';
}
};
const backups = backupsData?.success ? backupsData.backups : [];
const isDiscovering = discoverMutation.isPending;
return (
<div className="space-y-6">
{/* Header with refresh button */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-foreground">Backups</h2>
<p className="text-sm text-muted-foreground mt-1">
Discovered backups grouped by container ID
</p>
</div>
<Button
onClick={handleDiscoverBackups}
disabled={isDiscovering}
className="flex items-center gap-2"
>
<RefreshCw className={`h-4 w-4 ${isDiscovering ? 'animate-spin' : ''}`} />
{isDiscovering ? 'Discovering...' : 'Discover Backups'}
</Button>
</div>
{/* Loading state */}
{(isLoading || isDiscovering) && backups.length === 0 && (
<div className="bg-card rounded-lg border border-border p-8 text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">
{isDiscovering ? 'Discovering backups...' : 'Loading backups...'}
</p>
</div>
)}
{/* Empty state */}
{!isLoading && !isDiscovering && backups.length === 0 && (
<div className="bg-card rounded-lg border border-border p-8 text-center">
<HardDrive className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold text-foreground mb-2">No backups found</h3>
<p className="text-muted-foreground mb-4">
Click "Discover Backups" to scan for backups on your servers.
</p>
<Button onClick={handleDiscoverBackups} disabled={isDiscovering}>
<RefreshCw className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} />
Discover Backups
</Button>
</div>
)}
{/* Backups list */}
{!isLoading && backups.length > 0 && (
<div className="space-y-4">
{backups.map((container: ContainerBackups) => {
const isExpanded = expandedContainers.has(container.container_id);
const backupCount = container.backups.length;
return (
<div
key={container.container_id}
className="bg-card rounded-lg border border-border shadow-sm overflow-hidden"
>
{/* Container header - collapsible */}
<button
onClick={() => toggleContainer(container.container_id)}
className="w-full flex items-center justify-between p-4 hover:bg-accent/50 transition-colors text-left"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground">
CT {container.container_id}
</span>
{container.hostname && (
<>
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{container.hostname}</span>
</>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{backupCount} {backupCount === 1 ? 'backup' : 'backups'}
</p>
</div>
</div>
</button>
{/* Container content - backups list */}
{isExpanded && (
<div className="border-t border-border">
<div className="p-4 space-y-3">
{container.backups.map((backup) => (
<div
key={backup.id}
className="bg-muted/50 rounded-lg p-4 border border-border/50"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="font-medium text-foreground break-all">
{backup.backup_name}
</span>
<Badge
variant={getStorageTypeBadgeVariant(backup.storage_type)}
className="flex items-center gap-1"
>
{getStorageTypeIcon(backup.storage_type)}
{backup.storage_name}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
{backup.size && (
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{formatFileSize(backup.size)}
</span>
)}
{backup.created_at && (
<span>{formatDate(backup.created_at)}</span>
)}
{backup.server_name && (
<span className="flex items-center gap-1">
<Server className="h-3 w-3" />
{backup.server_name}
</span>
)}
</div>
<div className="mt-2">
<code className="text-xs text-muted-foreground break-all">
{backup.backup_path}
</code>
</div>
</div>
<div className="flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="bg-muted/20 hover:bg-muted/30 border border-muted text-muted-foreground hover:text-foreground hover:border-muted-foreground transition-all duration-200 hover:scale-105 hover:shadow-md"
>
Actions
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48 bg-card border-border">
<DropdownMenuItem
onClick={() => handleRestoreClick(backup, container.container_id)}
disabled={restoreMutation.isPending}
className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20"
>
Restore
</DropdownMenuItem>
<DropdownMenuItem
disabled
className="text-muted-foreground opacity-50"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* Error state */}
{backupsData && !backupsData.success && (
<div className="bg-destructive/10 border border-destructive rounded-lg p-4">
<p className="text-destructive">
Error loading backups: {backupsData.error || 'Unknown error'}
</p>
</div>
)}
{/* Restore Confirmation Modal */}
{selectedBackup && (
<ConfirmationModal
isOpen={restoreConfirmOpen}
onClose={() => {
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 || (restoreSuccess && restoreProgress.length > 0)) && (
<LoadingModal
isOpen={true}
action={currentProgressText}
logs={restoreProgress}
isComplete={restoreSuccess}
title="Restore in progress"
onClose={() => {
setRestoreSuccess(false);
setRestoreProgress([]);
}}
/>
)}
{/* Restore Success */}
{restoreSuccess && (
<div className="bg-success/10 border border-success/20 rounded-lg p-4">
<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-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-muted-foreground">
{restoreError}
</p>
{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>
)}
<Button
onClick={() => {
setRestoreError(null);
setRestoreProgress([]);
}}
variant="outline"
size="sm"
className="mt-3"
>
Dismiss
</Button>
</div>
)}
</div>
);
}