improved backup creation
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
package com.storycove.controller;
|
package com.storycove.controller;
|
||||||
|
|
||||||
|
import com.storycove.service.AsyncBackupService;
|
||||||
import com.storycove.service.DatabaseManagementService;
|
import com.storycove.service.DatabaseManagementService;
|
||||||
|
import com.storycove.service.LibraryService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -12,6 +14,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -21,6 +24,12 @@ public class DatabaseController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private DatabaseManagementService databaseManagementService;
|
private DatabaseManagementService databaseManagementService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AsyncBackupService asyncBackupService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private LibraryService libraryService;
|
||||||
|
|
||||||
@PostMapping("/backup")
|
@PostMapping("/backup")
|
||||||
public ResponseEntity<Resource> backupDatabase() {
|
public ResponseEntity<Resource> backupDatabase() {
|
||||||
try {
|
try {
|
||||||
@@ -83,19 +92,141 @@ public class DatabaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/backup-complete")
|
@PostMapping("/backup-complete")
|
||||||
public ResponseEntity<Resource> backupComplete() {
|
public ResponseEntity<Map<String, Object>> backupCompleteAsync() {
|
||||||
try {
|
try {
|
||||||
Resource backup = databaseManagementService.createCompleteBackup();
|
String libraryId = libraryService.getCurrentLibraryId();
|
||||||
|
if (libraryId == null) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(Map.of("success", false, "message", "No library selected"));
|
||||||
|
}
|
||||||
|
|
||||||
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
|
// Start backup job asynchronously
|
||||||
String filename = "storycove_complete_backup_" + timestamp + ".zip";
|
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<Map<String, Object>> getBackupStatus(@PathVariable String jobId) {
|
||||||
|
try {
|
||||||
|
java.util.UUID uuid = java.util.UUID.fromString(jobId);
|
||||||
|
java.util.Optional<com.storycove.entity.BackupJob> 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<Resource> downloadBackup(@PathVariable String jobId) {
|
||||||
|
try {
|
||||||
|
java.util.UUID uuid = java.util.UUID.fromString(jobId);
|
||||||
|
Resource backup = asyncBackupService.getBackupFile(uuid);
|
||||||
|
|
||||||
|
java.util.Optional<com.storycove.entity.BackupJob> 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()
|
return ResponseEntity.ok()
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
.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);
|
.body(backup);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
} catch (Exception e) {
|
} 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<Map<String, Object>> listBackups() {
|
||||||
|
try {
|
||||||
|
String libraryId = libraryService.getCurrentLibraryId();
|
||||||
|
if (libraryId == null) {
|
||||||
|
return ResponseEntity.badRequest()
|
||||||
|
.body(Map.of("success", false, "message", "No library selected"));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<com.storycove.entity.BackupJob> jobs = asyncBackupService.listBackupJobs(libraryId);
|
||||||
|
|
||||||
|
List<Map<String, Object>> jobsList = jobs.stream()
|
||||||
|
.map(job -> {
|
||||||
|
Map<String, Object> 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<Map<String, Object>> 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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
195
backend/src/main/java/com/storycove/entity/BackupJob.java
Normal file
195
backend/src/main/java/com/storycove/entity/BackupJob.java
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BackupJob, UUID> {
|
||||||
|
|
||||||
|
List<BackupJob> findByLibraryIdOrderByCreatedAtDesc(String libraryId);
|
||||||
|
|
||||||
|
@Query("SELECT bj FROM BackupJob bj WHERE bj.expiresAt < :now AND bj.status = 'COMPLETED'")
|
||||||
|
List<BackupJob> 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);
|
||||||
|
}
|
||||||
@@ -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<BackupJob> 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<BackupJob> getJobStatus(UUID jobId) {
|
||||||
|
return backupJobRepository.findById(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get backup file for download
|
||||||
|
*/
|
||||||
|
public Resource getBackupFile(UUID jobId) throws IOException {
|
||||||
|
Optional<BackupJob> 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<BackupJob> 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<BackupJob> 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<BackupJob> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,11 +33,18 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [databaseStatus, setDatabaseStatus] = useState<{
|
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 };
|
completeRestore: { loading: boolean; message: string; success?: boolean };
|
||||||
completeClear: { 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: '' },
|
completeRestore: { loading: false, message: '' },
|
||||||
completeClear: { loading: false, message: '' }
|
completeClear: { loading: false, message: '' }
|
||||||
});
|
});
|
||||||
@@ -73,43 +80,117 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
const handleCompleteBackup = async () => {
|
const handleCompleteBackup = async () => {
|
||||||
setDatabaseStatus(prev => ({
|
setDatabaseStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
completeBackup: { loading: true, message: 'Creating complete backup...', success: undefined }
|
completeBackup: { loading: true, message: 'Starting backup...', success: undefined, progress: 0, downloadReady: false }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backupBlob = await databaseApi.backupComplete();
|
// Start the async backup job
|
||||||
|
const startResponse = await databaseApi.backupComplete();
|
||||||
// Create download link
|
const jobId = startResponse.jobId;
|
||||||
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);
|
|
||||||
|
|
||||||
setDatabaseStatus(prev => ({
|
setDatabaseStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
completeBackup: { loading: false, message: 'Complete backup downloaded successfully', success: true }
|
completeBackup: { ...prev.completeBackup, jobId, message: 'Backup in progress...' }
|
||||||
}));
|
}));
|
||||||
} catch (error: any) {
|
|
||||||
|
// Poll for progress
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const status = await databaseApi.getBackupStatus(jobId);
|
||||||
|
|
||||||
|
if (status.status === 'COMPLETED') {
|
||||||
|
clearInterval(pollInterval);
|
||||||
setDatabaseStatus(prev => ({
|
setDatabaseStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
completeBackup: { loading: false, message: error.message || 'Complete backup failed', success: false }
|
completeBackup: {
|
||||||
}));
|
loading: false,
|
||||||
|
message: 'Backup completed! Ready to download.',
|
||||||
|
success: true,
|
||||||
|
jobId,
|
||||||
|
progress: 100,
|
||||||
|
downloadReady: true
|
||||||
}
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
// Clear message after 5 seconds
|
// Clear message after 30 seconds (keep download button visible)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDatabaseStatus(prev => ({
|
setDatabaseStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
completeBackup: { loading: false, message: '', success: undefined }
|
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 || 'Failed to start backup',
|
||||||
|
success: false,
|
||||||
|
progress: 0,
|
||||||
|
downloadReady: false
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
}, 5000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompleteRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleCompleteRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -792,20 +873,50 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
|||||||
<p className="text-sm theme-text mb-3">
|
<p className="text-sm theme-text mb-3">
|
||||||
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.
|
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.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCompleteBackup}
|
onClick={handleCompleteBackup}
|
||||||
disabled={databaseStatus.completeBackup.loading}
|
disabled={databaseStatus.completeBackup.loading || databaseStatus.completeBackup.downloadReady}
|
||||||
loading={databaseStatus.completeBackup.loading}
|
loading={databaseStatus.completeBackup.loading}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Download Backup'}
|
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Create Backup'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{databaseStatus.completeBackup.downloadReady && databaseStatus.completeBackup.jobId && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownloadBackup(databaseStatus.completeBackup.jobId!)}
|
||||||
|
variant="primary"
|
||||||
|
className="w-full sm:w-auto ml-0 sm:ml-3 bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
⬇️ Download Backup
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{databaseStatus.completeBackup.loading && databaseStatus.completeBackup.progress !== undefined && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-between text-sm theme-text mb-1">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>{databaseStatus.completeBackup.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 dark:bg-blue-500 h-2.5 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${databaseStatus.completeBackup.progress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{databaseStatus.completeBackup.message && (
|
{databaseStatus.completeBackup.message && (
|
||||||
<div className={`text-sm p-2 rounded mt-3 ${
|
<div className={`text-sm p-2 rounded mt-3 ${
|
||||||
databaseStatus.completeBackup.success
|
databaseStatus.completeBackup.success
|
||||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
: databaseStatus.completeBackup.success === false
|
||||||
|
? 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||||
|
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200'
|
||||||
}`}>
|
}`}>
|
||||||
{databaseStatus.completeBackup.message}
|
{databaseStatus.completeBackup.message}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1013,10 +1013,47 @@ export const databaseApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
backupComplete: async (): Promise<Blob> => {
|
backupComplete: async (): Promise<{ success: boolean; jobId: string; status: string; message: string }> => {
|
||||||
const response = await api.post('/database/backup-complete', {}, {
|
const response = await api.post('/database/backup-complete');
|
||||||
responseType: 'blob'
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user