b6fb712c9f
Mirror to GitHub / mirror (push) Successful in 36s
Test and Lint / backend-test (push) Successful in 1m29s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s
- Change default import to named import for api from config/api.ts - Fixes build error: 'default' is not exported by src/config/api.ts - Affected files: BackupHistory.jsx, RestoreWizard.jsx, BackupManagement.jsx 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
411 lines
18 KiB
React
411 lines
18 KiB
React
import React, { useState } from 'react';
|
|
import {
|
|
Download,
|
|
Eye,
|
|
Trash2,
|
|
Search,
|
|
Filter,
|
|
CheckCircle,
|
|
XCircle,
|
|
Clock,
|
|
AlertCircle,
|
|
FileArchive,
|
|
Database,
|
|
Image,
|
|
HardDrive,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Calendar,
|
|
RefreshCw,
|
|
Loader2
|
|
} from 'lucide-react';
|
|
import { format, formatDistanceToNow } from 'date-fns';
|
|
import { toast } from 'react-toastify';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Button, Card, Input, Loading } from '../common';
|
|
import { api } from '../../config/api';
|
|
|
|
const statusIcons = {
|
|
completed: { icon: CheckCircle, color: 'text-green-500' },
|
|
failed: { icon: XCircle, color: 'text-red-500' },
|
|
running: { icon: Loader2, color: 'text-blue-500 animate-spin' },
|
|
partial: { icon: AlertCircle, color: 'text-amber-500' }
|
|
};
|
|
|
|
const formatBytes = (bytes) => {
|
|
if (!bytes) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
};
|
|
|
|
export const BackupHistory = () => {
|
|
const [expandedRows, setExpandedRows] = useState(new Set());
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState('all');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const queryClient = useQueryClient();
|
|
|
|
// Fetch backup history
|
|
const { data, isLoading, refetch } = useQuery({
|
|
queryKey: ['backup-history', currentPage, searchTerm, filterStatus],
|
|
queryFn: async () => {
|
|
const params = new URLSearchParams({
|
|
page: currentPage,
|
|
limit: 20,
|
|
...(searchTerm && { search: searchTerm }),
|
|
...(filterStatus !== 'all' && { status: filterStatus })
|
|
});
|
|
const response = await api.get(`/admin/backup/status?${params}`);
|
|
return response.data;
|
|
}
|
|
});
|
|
|
|
// Delete backup mutation
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async (backupId) => {
|
|
const response = await api.delete(`/admin/backup/runs/${backupId}`);
|
|
return response.data;
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Backup deleted successfully');
|
|
queryClient.invalidateQueries({ queryKey: ['backup-history'] });
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error.response?.data?.error || 'Failed to delete backup');
|
|
}
|
|
});
|
|
|
|
const toggleRowExpansion = (id) => {
|
|
const newExpanded = new Set(expandedRows);
|
|
if (newExpanded.has(id)) {
|
|
newExpanded.delete(id);
|
|
} else {
|
|
newExpanded.add(id);
|
|
}
|
|
setExpandedRows(newExpanded);
|
|
};
|
|
|
|
const handleDelete = (backup) => {
|
|
if (window.confirm(`Are you sure you want to delete this backup from ${format(new Date(backup.created_at), 'PPP')}?`)) {
|
|
deleteMutation.mutate(backup.id);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <Loading />;
|
|
}
|
|
|
|
const backups = data?.recentBackups || [];
|
|
const pagination = data?.pagination || {};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Search and Filters */}
|
|
<Card className="p-4">
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
<div className="flex-1">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search backups..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<select
|
|
value={filterStatus}
|
|
onChange={(e) => setFilterStatus(e.target.value)}
|
|
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
|
|
>
|
|
<option value="all">All Status</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="failed">Failed</option>
|
|
<option value="running">Running</option>
|
|
<option value="partial">Partial</option>
|
|
</select>
|
|
|
|
<Button
|
|
onClick={() => refetch()}
|
|
variant="secondary"
|
|
size="sm"
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Backup History Table */}
|
|
<Card className="overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="bg-gray-50 border-b border-gray-200">
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Status
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Date & Time
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Type
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Size
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Duration
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{backups.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
|
<FileArchive className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
|
<p className="text-lg font-medium">No backups found</p>
|
|
<p className="text-sm mt-1">Backups will appear here once created</p>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
backups.map((backup) => {
|
|
const StatusIcon = statusIcons[backup.status]?.icon || AlertCircle;
|
|
const statusColor = statusIcons[backup.status]?.color || 'text-gray-500';
|
|
const isExpanded = expandedRows.has(backup.id);
|
|
const stats = backup.statistics || {};
|
|
|
|
return (
|
|
<React.Fragment key={backup.id}>
|
|
<tr className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
<StatusIcon className={`h-5 w-5 ${statusColor}`} />
|
|
<span className="ml-2 text-sm font-medium text-gray-900 capitalize">
|
|
{backup.status}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900">
|
|
{format(new Date(backup.created_at), 'PPP')}
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
{format(new Date(backup.created_at), 'p')} • {formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
|
|
{backup.backup_type || 'Manual'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<p className="text-sm text-gray-900">
|
|
{formatBytes(stats.total_size || 0)}
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
{stats.files_processed || 0} files
|
|
</p>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{backup.duration_seconds
|
|
? `${Math.round(backup.duration_seconds / 60)}m ${backup.duration_seconds % 60}s`
|
|
: '-'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
<div className="flex items-center justify-end space-x-2">
|
|
<button
|
|
onClick={() => toggleRowExpansion(backup.id)}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
title="View details"
|
|
>
|
|
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
|
</button>
|
|
{backup.manifest_path && (
|
|
<button
|
|
onClick={() => window.open(`/admin/backup/download/${backup.id}`, '_blank')}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
title="Download backup"
|
|
>
|
|
<Download size={20} />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handleDelete(backup)}
|
|
className="text-gray-400 hover:text-red-600"
|
|
title="Delete backup"
|
|
disabled={deleteMutation.isLoading}
|
|
>
|
|
<Trash2 size={20} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Expanded Details Row */}
|
|
{isExpanded && (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-4 bg-gray-50">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{/* Backup Details */}
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium text-gray-900">Backup Details</h4>
|
|
<div className="text-sm space-y-1">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500">Destination:</span>
|
|
<span className="text-gray-900">{backup.destination_type || 'Unknown'}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500">Started:</span>
|
|
<span className="text-gray-900">{format(new Date(backup.created_at), 'p')}</span>
|
|
</div>
|
|
{backup.completed_at && (
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-500">Completed:</span>
|
|
<span className="text-gray-900">{format(new Date(backup.completed_at), 'p')}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content Backed Up */}
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium text-gray-900">Content Backed Up</h4>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center space-x-2">
|
|
<Database className={`h-4 w-4 ${stats.database_backed_up ? 'text-green-500' : 'text-gray-300'}`} />
|
|
<span className="text-sm text-gray-700">Database</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
|
|
<span className="text-sm text-gray-700">
|
|
Photos ({stats.photos_backed_up || 0} of {stats.total_photos || 0})
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<FileArchive className={`h-4 w-4 ${stats.archives_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
|
|
<span className="text-sm text-gray-700">
|
|
Archives ({stats.archives_backed_up || 0})
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Information */}
|
|
{backup.error_message && (
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium text-red-900">Error Details</h4>
|
|
<p className="text-sm text-red-700 bg-red-50 p-2 rounded">
|
|
{backup.error_message}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Manifest Path */}
|
|
{backup.manifest_path && (
|
|
<div className="space-y-2">
|
|
<h4 className="font-medium text-gray-900">Manifest</h4>
|
|
<p className="text-sm text-gray-600 font-mono break-all">
|
|
{backup.manifest_path}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{pagination.pages > 1 && (
|
|
<div className="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex-1 flex justify-between sm:hidden">
|
|
<Button
|
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
variant="secondary"
|
|
size="sm"
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
|
|
disabled={currentPage === pagination.pages}
|
|
variant="secondary"
|
|
size="sm"
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-700">
|
|
Showing <span className="font-medium">{(currentPage - 1) * pagination.limit + 1}</span> to{' '}
|
|
<span className="font-medium">
|
|
{Math.min(currentPage * pagination.limit, pagination.total)}
|
|
</span>{' '}
|
|
of <span className="font-medium">{pagination.total}</span> results
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
|
disabled={currentPage === 1}
|
|
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Previous
|
|
</button>
|
|
|
|
{[...Array(Math.min(5, pagination.pages))].map((_, i) => {
|
|
const pageNum = i + 1;
|
|
return (
|
|
<button
|
|
key={pageNum}
|
|
onClick={() => setCurrentPage(pageNum)}
|
|
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
|
currentPage === pageNum
|
|
? 'z-10 bg-primary-50 border-primary text-primary'
|
|
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
{pageNum}
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
<button
|
|
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
|
|
disabled={currentPage === pagination.pages}
|
|
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Next
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}; |