From c9d58173f3fee7ff1648b1dbe812de1b6a4ebcd2 Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Mon, 20 Oct 2025 09:23:34 +0200 Subject: [PATCH] improved backup creation --- .../controller/DatabaseController.java | 147 ++++++++++- .../java/com/storycove/entity/BackupJob.java | 195 ++++++++++++++ .../repository/BackupJobRepository.java | 25 ++ .../storycove/service/AsyncBackupService.java | 245 ++++++++++++++++++ .../components/settings/SystemSettings.tsx | 183 ++++++++++--- frontend/src/lib/api.ts | 45 +++- 6 files changed, 792 insertions(+), 48 deletions(-) create mode 100644 backend/src/main/java/com/storycove/entity/BackupJob.java create mode 100644 backend/src/main/java/com/storycove/repository/BackupJobRepository.java create mode 100644 backend/src/main/java/com/storycove/service/AsyncBackupService.java diff --git a/backend/src/main/java/com/storycove/controller/DatabaseController.java b/backend/src/main/java/com/storycove/controller/DatabaseController.java index e016bef..28906a8 100644 --- a/backend/src/main/java/com/storycove/controller/DatabaseController.java +++ b/backend/src/main/java/com/storycove/controller/DatabaseController.java @@ -1,6 +1,8 @@ package com.storycove.controller; +import com.storycove.service.AsyncBackupService; import com.storycove.service.DatabaseManagementService; +import com.storycove.service.LibraryService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; @@ -12,6 +14,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; import java.util.Map; @RestController @@ -21,6 +24,12 @@ public class DatabaseController { @Autowired private DatabaseManagementService databaseManagementService; + @Autowired + private AsyncBackupService asyncBackupService; + + @Autowired + private LibraryService libraryService; + @PostMapping("/backup") public ResponseEntity backupDatabase() { try { @@ -83,19 +92,141 @@ public class DatabaseController { } @PostMapping("/backup-complete") - public ResponseEntity backupComplete() { + public ResponseEntity> backupCompleteAsync() { try { - Resource backup = databaseManagementService.createCompleteBackup(); - - String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")); - String filename = "storycove_complete_backup_" + timestamp + ".zip"; - + String libraryId = libraryService.getCurrentLibraryId(); + if (libraryId == null) { + return ResponseEntity.badRequest() + .body(Map.of("success", false, "message", "No library selected")); + } + + // Start backup job asynchronously + com.storycove.entity.BackupJob job = asyncBackupService.startBackupJob( + libraryId, + com.storycove.entity.BackupJob.BackupType.COMPLETE + ); + + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Backup started", + "jobId", job.getId().toString(), + "status", job.getStatus().toString() + )); + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(Map.of("success", false, "message", "Failed to start backup: " + e.getMessage())); + } + } + + @GetMapping("/backup-status/{jobId}") + public ResponseEntity> getBackupStatus(@PathVariable String jobId) { + try { + java.util.UUID uuid = java.util.UUID.fromString(jobId); + java.util.Optional jobOpt = asyncBackupService.getJobStatus(uuid); + + if (jobOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + com.storycove.entity.BackupJob job = jobOpt.get(); + + return ResponseEntity.ok(Map.of( + "success", true, + "jobId", job.getId().toString(), + "status", job.getStatus().toString(), + "progress", job.getProgressPercent(), + "fileSizeBytes", job.getFileSizeBytes() != null ? job.getFileSizeBytes() : 0, + "createdAt", job.getCreatedAt().toString(), + "completedAt", job.getCompletedAt() != null ? job.getCompletedAt().toString() : "", + "errorMessage", job.getErrorMessage() != null ? job.getErrorMessage() : "" + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest() + .body(Map.of("success", false, "message", "Invalid job ID")); + } + } + + @GetMapping("/backup-download/{jobId}") + public ResponseEntity downloadBackup(@PathVariable String jobId) { + try { + java.util.UUID uuid = java.util.UUID.fromString(jobId); + Resource backup = asyncBackupService.getBackupFile(uuid); + + java.util.Optional jobOpt = asyncBackupService.getJobStatus(uuid); + if (jobOpt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + com.storycove.entity.BackupJob job = jobOpt.get(); + String timestamp = job.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")); + String extension = job.getType() == com.storycove.entity.BackupJob.BackupType.COMPLETE ? "zip" : "sql"; + String filename = "storycove_backup_" + timestamp + "." + extension; + return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") - .header(HttpHeaders.CONTENT_TYPE, "application/zip") + .header(HttpHeaders.CONTENT_TYPE, + job.getType() == com.storycove.entity.BackupJob.BackupType.COMPLETE + ? "application/zip" + : "application/sql") .body(backup); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); } catch (Exception e) { - throw new RuntimeException("Failed to create complete backup: " + e.getMessage(), e); + throw new RuntimeException("Failed to download backup: " + e.getMessage(), e); + } + } + + @GetMapping("/backup-list") + public ResponseEntity> listBackups() { + try { + String libraryId = libraryService.getCurrentLibraryId(); + if (libraryId == null) { + return ResponseEntity.badRequest() + .body(Map.of("success", false, "message", "No library selected")); + } + + List jobs = asyncBackupService.listBackupJobs(libraryId); + + List> jobsList = jobs.stream() + .map(job -> { + Map jobMap = new java.util.HashMap<>(); + jobMap.put("jobId", job.getId().toString()); + jobMap.put("type", job.getType().toString()); + jobMap.put("status", job.getStatus().toString()); + jobMap.put("progress", job.getProgressPercent()); + jobMap.put("fileSizeBytes", job.getFileSizeBytes() != null ? job.getFileSizeBytes() : 0L); + jobMap.put("createdAt", job.getCreatedAt().toString()); + jobMap.put("completedAt", job.getCompletedAt() != null ? job.getCompletedAt().toString() : ""); + return jobMap; + }) + .collect(java.util.stream.Collectors.toList()); + + return ResponseEntity.ok(Map.of( + "success", true, + "backups", jobsList + )); + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(Map.of("success", false, "message", "Failed to list backups: " + e.getMessage())); + } + } + + @DeleteMapping("/backup/{jobId}") + public ResponseEntity> deleteBackup(@PathVariable String jobId) { + try { + java.util.UUID uuid = java.util.UUID.fromString(jobId); + asyncBackupService.deleteBackupJob(uuid); + + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Backup deleted successfully" + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest() + .body(Map.of("success", false, "message", "Invalid job ID")); + } catch (Exception e) { + return ResponseEntity.internalServerError() + .body(Map.of("success", false, "message", "Failed to delete backup: " + e.getMessage())); } } diff --git a/backend/src/main/java/com/storycove/entity/BackupJob.java b/backend/src/main/java/com/storycove/entity/BackupJob.java new file mode 100644 index 0000000..189b62c --- /dev/null +++ b/backend/src/main/java/com/storycove/entity/BackupJob.java @@ -0,0 +1,195 @@ +package com.storycove.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "backup_jobs") +public class BackupJob { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String libraryId; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private BackupType type; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private BackupStatus status; + + @Column + private String filePath; + + @Column + private Long fileSizeBytes; + + @Column + private Integer progressPercent; + + @Column(length = 1000) + private String errorMessage; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @Column + private LocalDateTime startedAt; + + @Column + private LocalDateTime completedAt; + + @Column + private LocalDateTime expiresAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + // Backups expire after 24 hours + expiresAt = LocalDateTime.now().plusDays(1); + } + + // Enums + public enum BackupType { + DATABASE_ONLY, + COMPLETE + } + + public enum BackupStatus { + PENDING, + IN_PROGRESS, + COMPLETED, + FAILED, + EXPIRED + } + + // Constructors + public BackupJob() { + } + + public BackupJob(String libraryId, BackupType type) { + this.libraryId = libraryId; + this.type = type; + this.status = BackupStatus.PENDING; + this.progressPercent = 0; + } + + // Getters and Setters + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getLibraryId() { + return libraryId; + } + + public void setLibraryId(String libraryId) { + this.libraryId = libraryId; + } + + public BackupType getType() { + return type; + } + + public void setType(BackupType type) { + this.type = type; + } + + public BackupStatus getStatus() { + return status; + } + + public void setStatus(BackupStatus status) { + this.status = status; + } + + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public Integer getProgressPercent() { + return progressPercent; + } + + public void setProgressPercent(Integer progressPercent) { + this.progressPercent = progressPercent; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getStartedAt() { + return startedAt; + } + + public void setStartedAt(LocalDateTime startedAt) { + this.startedAt = startedAt; + } + + public LocalDateTime getCompletedAt() { + return completedAt; + } + + public void setCompletedAt(LocalDateTime completedAt) { + this.completedAt = completedAt; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + // Helper methods + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } + + public boolean isCompleted() { + return status == BackupStatus.COMPLETED; + } + + public boolean isFailed() { + return status == BackupStatus.FAILED; + } + + public boolean isInProgress() { + return status == BackupStatus.IN_PROGRESS; + } +} diff --git a/backend/src/main/java/com/storycove/repository/BackupJobRepository.java b/backend/src/main/java/com/storycove/repository/BackupJobRepository.java new file mode 100644 index 0000000..86cfe46 --- /dev/null +++ b/backend/src/main/java/com/storycove/repository/BackupJobRepository.java @@ -0,0 +1,25 @@ +package com.storycove.repository; + +import com.storycove.entity.BackupJob; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Repository +public interface BackupJobRepository extends JpaRepository { + + List findByLibraryIdOrderByCreatedAtDesc(String libraryId); + + @Query("SELECT bj FROM BackupJob bj WHERE bj.expiresAt < :now AND bj.status = 'COMPLETED'") + List findExpiredJobs(@Param("now") LocalDateTime now); + + @Modifying + @Query("UPDATE BackupJob bj SET bj.status = 'EXPIRED' WHERE bj.expiresAt < :now AND bj.status = 'COMPLETED'") + int markExpiredJobs(@Param("now") LocalDateTime now); +} diff --git a/backend/src/main/java/com/storycove/service/AsyncBackupService.java b/backend/src/main/java/com/storycove/service/AsyncBackupService.java new file mode 100644 index 0000000..0570375 --- /dev/null +++ b/backend/src/main/java/com/storycove/service/AsyncBackupService.java @@ -0,0 +1,245 @@ +package com.storycove.service; + +import com.storycove.entity.BackupJob; +import com.storycove.repository.BackupJobRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class AsyncBackupService { + + private static final Logger logger = LoggerFactory.getLogger(AsyncBackupService.class); + + @Value("${storycove.upload.dir:/app/images}") + private String uploadDir; + + @Autowired + private BackupJobRepository backupJobRepository; + + @Autowired + private DatabaseManagementService databaseManagementService; + + @Autowired + private LibraryService libraryService; + + /** + * Start a backup job asynchronously + */ + @Transactional + public BackupJob startBackupJob(String libraryId, BackupJob.BackupType type) { + BackupJob job = new BackupJob(libraryId, type); + job = backupJobRepository.save(job); + + // Start backup in background + executeBackupAsync(job.getId()); + + return job; + } + + /** + * Execute backup asynchronously + */ + @Async + @Transactional + public void executeBackupAsync(UUID jobId) { + Optional jobOpt = backupJobRepository.findById(jobId); + if (jobOpt.isEmpty()) { + logger.error("Backup job not found: {}", jobId); + return; + } + + BackupJob job = jobOpt.get(); + job.setStatus(BackupJob.BackupStatus.IN_PROGRESS); + job.setStartedAt(LocalDateTime.now()); + job.setProgressPercent(0); + backupJobRepository.save(job); + + try { + logger.info("Starting backup job {} for library {}", job.getId(), job.getLibraryId()); + + // Switch to the correct library + if (!job.getLibraryId().equals(libraryService.getCurrentLibraryId())) { + libraryService.switchToLibraryAfterAuthentication(job.getLibraryId()); + } + + // Create backup file + Path backupDir = Paths.get(uploadDir, "backups", job.getLibraryId()); + Files.createDirectories(backupDir); + + String filename = String.format("backup_%s_%s.%s", + job.getId().toString(), + LocalDateTime.now().toString().replaceAll(":", "-"), + job.getType() == BackupJob.BackupType.COMPLETE ? "zip" : "sql"); + + Path backupFile = backupDir.resolve(filename); + + job.setProgressPercent(10); + backupJobRepository.save(job); + + // Create the backup + Resource backupResource; + if (job.getType() == BackupJob.BackupType.COMPLETE) { + backupResource = databaseManagementService.createCompleteBackup(); + } else { + backupResource = databaseManagementService.createBackup(); + } + + job.setProgressPercent(80); + backupJobRepository.save(job); + + // Copy resource to permanent file + try (var inputStream = backupResource.getInputStream(); + var outputStream = Files.newOutputStream(backupFile)) { + inputStream.transferTo(outputStream); + } + + job.setProgressPercent(95); + backupJobRepository.save(job); + + // Set file info + job.setFilePath(backupFile.toString()); + job.setFileSizeBytes(Files.size(backupFile)); + job.setStatus(BackupJob.BackupStatus.COMPLETED); + job.setCompletedAt(LocalDateTime.now()); + job.setProgressPercent(100); + + logger.info("Backup job {} completed successfully. File size: {} bytes", + job.getId(), job.getFileSizeBytes()); + + } catch (Exception e) { + logger.error("Backup job {} failed", job.getId(), e); + job.setStatus(BackupJob.BackupStatus.FAILED); + job.setErrorMessage(e.getMessage()); + job.setCompletedAt(LocalDateTime.now()); + } finally { + backupJobRepository.save(job); + } + } + + /** + * Get backup job status + */ + public Optional getJobStatus(UUID jobId) { + return backupJobRepository.findById(jobId); + } + + /** + * Get backup file for download + */ + public Resource getBackupFile(UUID jobId) throws IOException { + Optional jobOpt = backupJobRepository.findById(jobId); + if (jobOpt.isEmpty()) { + throw new IOException("Backup job not found"); + } + + BackupJob job = jobOpt.get(); + + if (!job.isCompleted()) { + throw new IOException("Backup is not completed yet"); + } + + if (job.isExpired()) { + throw new IOException("Backup has expired"); + } + + if (job.getFilePath() == null) { + throw new IOException("Backup file path not set"); + } + + Path backupPath = Paths.get(job.getFilePath()); + if (!Files.exists(backupPath)) { + throw new IOException("Backup file not found"); + } + + return new FileSystemResource(backupPath); + } + + /** + * List backup jobs for a library + */ + public List listBackupJobs(String libraryId) { + return backupJobRepository.findByLibraryIdOrderByCreatedAtDesc(libraryId); + } + + /** + * Clean up expired backup jobs and their files + * Runs daily at 2 AM + */ + @Scheduled(cron = "0 0 2 * * ?") + @Transactional + public void cleanupExpiredBackups() { + logger.info("Starting cleanup of expired backups"); + + LocalDateTime now = LocalDateTime.now(); + + // Mark expired jobs + int markedCount = backupJobRepository.markExpiredJobs(now); + logger.info("Marked {} jobs as expired", markedCount); + + // Find all expired jobs to delete their files + List expiredJobs = backupJobRepository.findExpiredJobs(now); + + for (BackupJob job : expiredJobs) { + if (job.getFilePath() != null) { + try { + Path filePath = Paths.get(job.getFilePath()); + if (Files.exists(filePath)) { + Files.delete(filePath); + logger.info("Deleted expired backup file: {}", filePath); + } + } catch (IOException e) { + logger.warn("Failed to delete expired backup file: {}", job.getFilePath(), e); + } + } + + // Delete the job record + backupJobRepository.delete(job); + } + + logger.info("Cleanup completed. Deleted {} expired backups", expiredJobs.size()); + } + + /** + * Delete a specific backup job and its file + */ + @Transactional + public void deleteBackupJob(UUID jobId) throws IOException { + Optional jobOpt = backupJobRepository.findById(jobId); + if (jobOpt.isEmpty()) { + throw new IOException("Backup job not found"); + } + + BackupJob job = jobOpt.get(); + + // Delete file if it exists + if (job.getFilePath() != null) { + Path filePath = Paths.get(job.getFilePath()); + if (Files.exists(filePath)) { + Files.delete(filePath); + logger.info("Deleted backup file: {}", filePath); + } + } + + // Delete job record + backupJobRepository.delete(job); + logger.info("Deleted backup job: {}", jobId); + } +} diff --git a/frontend/src/components/settings/SystemSettings.tsx b/frontend/src/components/settings/SystemSettings.tsx index 0df0f6e..3bef91c 100644 --- a/frontend/src/components/settings/SystemSettings.tsx +++ b/frontend/src/components/settings/SystemSettings.tsx @@ -33,11 +33,18 @@ export default function SystemSettings({}: SystemSettingsProps) { }); const [databaseStatus, setDatabaseStatus] = useState<{ - completeBackup: { loading: boolean; message: string; success?: boolean }; + completeBackup: { + loading: boolean; + message: string; + success?: boolean; + jobId?: string; + progress?: number; + downloadReady?: boolean; + }; completeRestore: { loading: boolean; message: string; success?: boolean }; completeClear: { loading: boolean; message: string; success?: boolean }; }>({ - completeBackup: { loading: false, message: '' }, + completeBackup: { loading: false, message: '', progress: 0 }, completeRestore: { loading: false, message: '' }, completeClear: { loading: false, message: '' } }); @@ -73,43 +80,117 @@ export default function SystemSettings({}: SystemSettingsProps) { const handleCompleteBackup = async () => { setDatabaseStatus(prev => ({ ...prev, - completeBackup: { loading: true, message: 'Creating complete backup...', success: undefined } + completeBackup: { loading: true, message: 'Starting backup...', success: undefined, progress: 0, downloadReady: false } })); try { - const backupBlob = await databaseApi.backupComplete(); - - // Create download link - const url = window.URL.createObjectURL(backupBlob); - const link = document.createElement('a'); - link.href = url; - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - link.download = `storycove_complete_backup_${timestamp}.zip`; - - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - window.URL.revokeObjectURL(url); + // Start the async backup job + const startResponse = await databaseApi.backupComplete(); + const jobId = startResponse.jobId; setDatabaseStatus(prev => ({ ...prev, - completeBackup: { loading: false, message: 'Complete backup downloaded successfully', success: true } + completeBackup: { ...prev.completeBackup, jobId, message: 'Backup in progress...' } })); + + // Poll for progress + const pollInterval = setInterval(async () => { + try { + const status = await databaseApi.getBackupStatus(jobId); + + if (status.status === 'COMPLETED') { + clearInterval(pollInterval); + setDatabaseStatus(prev => ({ + ...prev, + completeBackup: { + loading: false, + message: 'Backup completed! Ready to download.', + success: true, + jobId, + progress: 100, + downloadReady: true + } + })); + + // Clear message after 30 seconds (keep download button visible) + setTimeout(() => { + setDatabaseStatus(prev => ({ + ...prev, + completeBackup: { ...prev.completeBackup, message: '' } + })); + }, 30000); + } else if (status.status === 'FAILED') { + clearInterval(pollInterval); + setDatabaseStatus(prev => ({ + ...prev, + completeBackup: { + loading: false, + message: `Backup failed: ${status.errorMessage}`, + success: false, + progress: 0, + downloadReady: false + } + })); + } else { + // Update progress + setDatabaseStatus(prev => ({ + ...prev, + completeBackup: { + ...prev.completeBackup, + progress: status.progress, + message: `Creating backup... ${status.progress}%` + } + })); + } + } catch (pollError: any) { + clearInterval(pollInterval); + setDatabaseStatus(prev => ({ + ...prev, + completeBackup: { + loading: false, + message: `Failed to check backup status: ${pollError.message}`, + success: false, + progress: 0, + downloadReady: false + } + })); + } + }, 2000); // Poll every 2 seconds + } catch (error: any) { setDatabaseStatus(prev => ({ ...prev, - completeBackup: { loading: false, message: error.message || 'Complete backup failed', success: false } + completeBackup: { + loading: false, + message: error.message || 'Failed to start backup', + success: false, + progress: 0, + downloadReady: false + } })); } + }; - // Clear message after 5 seconds - setTimeout(() => { - setDatabaseStatus(prev => ({ - ...prev, - completeBackup: { loading: false, message: '', success: undefined } - })); - }, 5000); + const handleDownloadBackup = (jobId: string) => { + const downloadUrl = databaseApi.downloadBackup(jobId); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = ''; // Filename will be set by server + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clear the download ready state after download + setDatabaseStatus(prev => ({ + ...prev, + completeBackup: { + loading: false, + message: 'Backup downloaded successfully', + success: true, + progress: 100, + downloadReady: false + } + })); }; const handleCompleteRestore = async (event: React.ChangeEvent) => { @@ -792,20 +873,50 @@ export default function SystemSettings({}: SystemSettingsProps) {

Download a complete backup as a ZIP file. This includes your database AND all uploaded files (cover images, avatars). This is a comprehensive backup of your entire StoryCove installation.

- +
+ + + {databaseStatus.completeBackup.downloadReady && databaseStatus.completeBackup.jobId && ( + + )} +
+ + {databaseStatus.completeBackup.loading && databaseStatus.completeBackup.progress !== undefined && ( +
+
+ Progress + {databaseStatus.completeBackup.progress}% +
+
+
+
+
+ )} + {databaseStatus.completeBackup.message && (
{databaseStatus.completeBackup.message}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bbd57ec..80859aa 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1013,10 +1013,47 @@ export const databaseApi = { return response.data; }, - backupComplete: async (): Promise => { - const response = await api.post('/database/backup-complete', {}, { - responseType: 'blob' - }); + backupComplete: async (): Promise<{ success: boolean; jobId: string; status: string; message: string }> => { + const response = await api.post('/database/backup-complete'); + return response.data; + }, + + getBackupStatus: async (jobId: string): Promise<{ + success: boolean; + jobId: string; + status: string; + progress: number; + fileSizeBytes: number; + createdAt: string; + completedAt: string; + errorMessage: string; + }> => { + const response = await api.get(`/database/backup-status/${jobId}`); + return response.data; + }, + + downloadBackup: (jobId: string): string => { + return `/api/database/backup-download/${jobId}`; + }, + + listBackups: async (): Promise<{ + success: boolean; + backups: Array<{ + jobId: string; + type: string; + status: string; + progress: number; + fileSizeBytes: number; + createdAt: string; + completedAt: string; + }>; + }> => { + const response = await api.get('/database/backup-list'); + return response.data; + }, + + deleteBackup: async (jobId: string): Promise<{ success: boolean; message: string }> => { + const response = await api.delete(`/database/backup/${jobId}`); return response.data; },