mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
FEATURE (backups): Move backups to separate backup config and make feature optional
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<img src="assets/logo.svg" alt="Postgresus Logo" width="250"/>
|
||||
|
||||
<h3>PostgreSQL monitoring and backup</h3>
|
||||
<p>Free, open source and self-hosted solution for automated PostgreSQL monitoring and backups with multiple storage options and notifications</p>
|
||||
<p>Free, open source and self-hosted solution for automated PostgreSQL monitoring and backups. With multiple storage options and notifications</p>
|
||||
|
||||
<p>
|
||||
<a href="#-features">Features</a> •
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 31 KiB |
@@ -15,7 +15,7 @@ GOOSE_MIGRATION_DIR=./migrations
|
||||
# to get Google Drive env variables: add storage in UI and copy data from added storage here
|
||||
TEST_GOOGLE_DRIVE_CLIENT_ID=
|
||||
TEST_GOOGLE_DRIVE_CLIENT_SECRET=
|
||||
TEST_GOOGLE_DRIVE_TOKEN_JSON=
|
||||
TEST_GOOGLE_DRIVE_TOKEN_JSON="{\"access_token\":\"ya29..."
|
||||
# testing DBs
|
||||
TEST_POSTGRES_13_PORT=5001
|
||||
TEST_POSTGRES_14_PORT=5002
|
||||
|
||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -10,4 +10,5 @@ swagger/docs.go
|
||||
swagger/swagger.json
|
||||
swagger/swagger.yaml
|
||||
postgresus-backend.exe
|
||||
ui/build/*
|
||||
ui/build/*
|
||||
pgdata-for-restore/
|
||||
@@ -14,7 +14,8 @@ import (
|
||||
|
||||
"postgresus-backend/internal/config"
|
||||
"postgresus-backend/internal/downdetect"
|
||||
"postgresus-backend/internal/features/backups"
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/disk"
|
||||
healthcheck_attempt "postgresus-backend/internal/features/healthcheck/attempt"
|
||||
@@ -135,6 +136,7 @@ func setUpRoutes(r *gin.Engine) {
|
||||
healthcheckConfigController := healthcheck_config.GetHealthcheckConfigController()
|
||||
healthcheckAttemptController := healthcheck_attempt.GetHealthcheckAttemptController()
|
||||
diskController := disk.GetDiskController()
|
||||
backupConfigController := backups_config.GetBackupConfigController()
|
||||
|
||||
downdetectContoller.RegisterRoutes(v1)
|
||||
userController.RegisterRoutes(v1)
|
||||
@@ -147,10 +149,13 @@ func setUpRoutes(r *gin.Engine) {
|
||||
diskController.RegisterRoutes(v1)
|
||||
healthcheckConfigController.RegisterRoutes(v1)
|
||||
healthcheckAttemptController.RegisterRoutes(v1)
|
||||
backupConfigController.RegisterRoutes(v1)
|
||||
}
|
||||
|
||||
func setUpDependencies() {
|
||||
backups.SetupDependencies()
|
||||
backups.SetupDependencies()
|
||||
restores.SetupDependencies()
|
||||
healthcheck_config.SetupDependencies()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,19 @@ package backups
|
||||
import (
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/config"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"postgresus-backend/internal/util/period"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BackupBackgroundService struct {
|
||||
backupService *BackupService
|
||||
backupRepository *BackupRepository
|
||||
databaseService *databases.DatabaseService
|
||||
storageService *storages.StorageService
|
||||
backupService *BackupService
|
||||
backupRepository *BackupRepository
|
||||
databaseService *databases.DatabaseService
|
||||
storageService *storages.StorageService
|
||||
backupConfigService *backups_config.BackupConfigService
|
||||
|
||||
lastBackupTime time.Time
|
||||
logger *slog.Logger
|
||||
@@ -60,15 +63,21 @@ func (s *BackupBackgroundService) failBackupsInProgress() error {
|
||||
}
|
||||
|
||||
for _, backup := range backupsInProgress {
|
||||
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(backup.DatabaseID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get backup config by database ID", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
failMessage := "Backup failed due to application restart"
|
||||
backup.FailMessage = &failMessage
|
||||
backup.Status = BackupStatusFailed
|
||||
backup.BackupSizeMb = 0
|
||||
|
||||
s.backupService.SendBackupNotification(
|
||||
backup.Database,
|
||||
backupConfig,
|
||||
backup,
|
||||
databases.NotificationBackupFailed,
|
||||
backups_config.NotificationBackupFailed,
|
||||
&failMessage,
|
||||
)
|
||||
|
||||
@@ -87,9 +96,15 @@ func (s *BackupBackgroundService) cleanOldBackups() error {
|
||||
}
|
||||
|
||||
for _, database := range allDatabases {
|
||||
backupStorePeriod := database.StorePeriod
|
||||
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(database.ID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get backup config by database ID", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if backupStorePeriod == databases.PeriodForever {
|
||||
backupStorePeriod := backupConfig.StorePeriod
|
||||
|
||||
if backupStorePeriod == period.PeriodForever {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -148,7 +163,13 @@ func (s *BackupBackgroundService) runPendingBackups() error {
|
||||
}
|
||||
|
||||
for _, database := range allDatabases {
|
||||
if database.BackupInterval == nil {
|
||||
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(database.ID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get backup config by database ID", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if backupConfig.BackupInterval == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -169,13 +190,13 @@ func (s *BackupBackgroundService) runPendingBackups() error {
|
||||
lastBackupTime = &lastBackup.CreatedAt
|
||||
}
|
||||
|
||||
if database.BackupInterval.ShouldTriggerBackup(time.Now().UTC(), lastBackupTime) {
|
||||
if backupConfig.BackupInterval.ShouldTriggerBackup(time.Now().UTC(), lastBackupTime) {
|
||||
s.logger.Info(
|
||||
"Triggering scheduled backup",
|
||||
"databaseId",
|
||||
database.ID,
|
||||
"intervalType",
|
||||
database.BackupInterval.Interval,
|
||||
backupConfig.BackupInterval.Interval,
|
||||
)
|
||||
|
||||
go s.backupService.MakeBackup(database.ID)
|
||||
@@ -1,7 +1,8 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/backups/usecases"
|
||||
"postgresus-backend/internal/features/backups/backups/usecases"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
@@ -17,8 +18,10 @@ var backupService = &BackupService{
|
||||
backupRepository,
|
||||
notifiers.GetNotifierService(),
|
||||
notifiers.GetNotifierService(),
|
||||
backups_config.GetBackupConfigService(),
|
||||
usecases.GetCreateBackupUsecase(),
|
||||
logger.GetLogger(),
|
||||
[]BackupRemoveListener{},
|
||||
}
|
||||
|
||||
var backupBackgroundService = &BackupBackgroundService{
|
||||
@@ -26,6 +29,7 @@ var backupBackgroundService = &BackupBackgroundService{
|
||||
backupRepository,
|
||||
databases.GetDatabaseService(),
|
||||
storages.GetStorageService(),
|
||||
backups_config.GetBackupConfigService(),
|
||||
time.Now().UTC(),
|
||||
logger.GetLogger(),
|
||||
}
|
||||
@@ -36,9 +40,11 @@ var backupController = &BackupController{
|
||||
}
|
||||
|
||||
func SetupDependencies() {
|
||||
databases.
|
||||
GetDatabaseService().
|
||||
backups_config.
|
||||
GetBackupConfigService().
|
||||
SetDatabaseStorageChangeListener(backupService)
|
||||
|
||||
databases.GetDatabaseService().AddDbRemoveListener(backupService)
|
||||
}
|
||||
|
||||
func GetBackupService() *BackupService {
|
||||
@@ -6,5 +6,4 @@ const (
|
||||
BackupStatusInProgress BackupStatus = "IN_PROGRESS"
|
||||
BackupStatusCompleted BackupStatus = "COMPLETED"
|
||||
BackupStatusFailed BackupStatus = "FAILED"
|
||||
BackupStatusDeleted BackupStatus = "DELETED"
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package backups
|
||||
|
||||
import (
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
@@ -19,6 +20,7 @@ type NotificationSender interface {
|
||||
type CreateBackupUsecase interface {
|
||||
Execute(
|
||||
backupID uuid.UUID,
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
database *databases.Database,
|
||||
storage *storages.Storage,
|
||||
backupProgressListener func(
|
||||
@@ -26,3 +28,7 @@ type CreateBackupUsecase interface {
|
||||
),
|
||||
) error
|
||||
}
|
||||
|
||||
type BackupRemoveListener interface {
|
||||
OnBeforeBackupRemove(backup *Backup) error
|
||||
}
|
||||
@@ -43,6 +43,22 @@ func (r *BackupRepository) FindByDatabaseID(databaseID uuid.UUID) ([]*Backup, er
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
func (r *BackupRepository) FindByStorageID(storageID uuid.UUID) ([]*Backup, error) {
|
||||
var backups []*Backup
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Preload("Database").
|
||||
Preload("Storage").
|
||||
Where("storage_id = ?", storageID).
|
||||
Order("created_at DESC").
|
||||
Find(&backups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
func (r *BackupRepository) FindLastByDatabaseID(databaseID uuid.UUID) (*Backup, error) {
|
||||
var backup Backup
|
||||
|
||||
@@ -113,6 +129,25 @@ func (r *BackupRepository) FindByStorageIdAndStatus(
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
func (r *BackupRepository) FindByDatabaseIdAndStatus(
|
||||
databaseID uuid.UUID,
|
||||
status BackupStatus,
|
||||
) ([]*Backup, error) {
|
||||
var backups []*Backup
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Preload("Database").
|
||||
Preload("Storage").
|
||||
Where("database_id = ? AND status = ?", databaseID, status).
|
||||
Order("created_at DESC").
|
||||
Find(&backups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
func (r *BackupRepository) DeleteByID(id uuid.UUID) error {
|
||||
return storage.GetDb().Delete(&Backup{}, "id = ?", id).Error
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
@@ -15,60 +16,42 @@ import (
|
||||
)
|
||||
|
||||
type BackupService struct {
|
||||
databaseService *databases.DatabaseService
|
||||
storageService *storages.StorageService
|
||||
backupRepository *BackupRepository
|
||||
notifierService *notifiers.NotifierService
|
||||
notificationSender NotificationSender
|
||||
databaseService *databases.DatabaseService
|
||||
storageService *storages.StorageService
|
||||
backupRepository *BackupRepository
|
||||
notifierService *notifiers.NotifierService
|
||||
notificationSender NotificationSender
|
||||
backupConfigService *backups_config.BackupConfigService
|
||||
|
||||
createBackupUseCase CreateBackupUsecase
|
||||
|
||||
logger *slog.Logger
|
||||
|
||||
backupRemoveListeners []BackupRemoveListener
|
||||
}
|
||||
|
||||
func (s *BackupService) OnBeforeDbStorageChange(
|
||||
func (s *BackupService) AddBackupRemoveListener(listener BackupRemoveListener) {
|
||||
s.backupRemoveListeners = append(s.backupRemoveListeners, listener)
|
||||
}
|
||||
|
||||
func (s *BackupService) OnBeforeBackupsStorageChange(
|
||||
databaseID uuid.UUID,
|
||||
storageID uuid.UUID,
|
||||
) error {
|
||||
// validate no backups in progress
|
||||
backups, err := s.backupRepository.FindByStorageIdAndStatus(
|
||||
storageID,
|
||||
BackupStatusInProgress,
|
||||
)
|
||||
err := s.deleteDbBackups(databaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(backups) > 0 {
|
||||
return errors.New("backup is in progress, storage cannot")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
backupsWithStorage, err := s.backupRepository.FindByStorageIdAndStatus(
|
||||
storageID,
|
||||
BackupStatusCompleted,
|
||||
)
|
||||
func (s *BackupService) OnBeforeDatabaseRemove(databaseID uuid.UUID) error {
|
||||
err := s.deleteDbBackups(databaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(backupsWithStorage) > 0 {
|
||||
for _, backup := range backupsWithStorage {
|
||||
if err := backup.Storage.DeleteFile(backup.ID); err != nil {
|
||||
// most likely we cannot do nothing with this,
|
||||
// so we just remove the backup model
|
||||
s.logger.Error("Failed to delete backup file", "error", err)
|
||||
}
|
||||
|
||||
if err := s.backupRepository.DeleteByID(backup.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// we repeat remove for the case if backup
|
||||
// started until we removed all previous backups
|
||||
return s.OnBeforeDbStorageChange(databaseID, storageID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -128,10 +111,7 @@ func (s *BackupService) DeleteBackup(
|
||||
return errors.New("backup is in progress")
|
||||
}
|
||||
|
||||
backup.DeleteBackupFromStorage(s.logger)
|
||||
|
||||
backup.Status = BackupStatusDeleted
|
||||
return s.backupRepository.Save(backup)
|
||||
return s.deleteBackup(backup)
|
||||
}
|
||||
|
||||
func (s *BackupService) MakeBackup(databaseID uuid.UUID) {
|
||||
@@ -152,7 +132,23 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID) {
|
||||
return
|
||||
}
|
||||
|
||||
storage, err := s.storageService.GetStorageByID(database.StorageID)
|
||||
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(databaseID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get backup config by database ID", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !backupConfig.IsBackupsEnabled {
|
||||
s.logger.Info("Backups are not enabled for this database")
|
||||
return
|
||||
}
|
||||
|
||||
if backupConfig.StorageID == nil {
|
||||
s.logger.Error("Backup config storage ID is not defined")
|
||||
return
|
||||
}
|
||||
|
||||
storage, err := s.storageService.GetStorageByID(*backupConfig.StorageID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get storage by ID", "error", err)
|
||||
return
|
||||
@@ -192,6 +188,7 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID) {
|
||||
|
||||
err = s.createBackupUseCase.Execute(
|
||||
backup.ID,
|
||||
backupConfig,
|
||||
database,
|
||||
storage,
|
||||
backupProgressListener,
|
||||
@@ -218,9 +215,9 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID) {
|
||||
}
|
||||
|
||||
s.SendBackupNotification(
|
||||
database,
|
||||
backupConfig,
|
||||
backup,
|
||||
databases.NotificationBackupFailed,
|
||||
backups_config.NotificationBackupFailed,
|
||||
&errMsg,
|
||||
)
|
||||
|
||||
@@ -248,27 +245,27 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID) {
|
||||
}
|
||||
|
||||
s.SendBackupNotification(
|
||||
database,
|
||||
backupConfig,
|
||||
backup,
|
||||
databases.NotificationBackupSuccess,
|
||||
backups_config.NotificationBackupSuccess,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *BackupService) SendBackupNotification(
|
||||
db *databases.Database,
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
backup *Backup,
|
||||
notificationType databases.BackupNotificationType,
|
||||
notificationType backups_config.BackupNotificationType,
|
||||
errorMessage *string,
|
||||
) {
|
||||
database, err := s.databaseService.GetDatabaseByID(db.ID)
|
||||
database, err := s.databaseService.GetDatabaseByID(backupConfig.DatabaseID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, notifier := range database.Notifiers {
|
||||
if !slices.Contains(
|
||||
database.SendNotificationsOn,
|
||||
backupConfig.SendNotificationsOn,
|
||||
notificationType,
|
||||
) {
|
||||
continue
|
||||
@@ -276,9 +273,9 @@ func (s *BackupService) SendBackupNotification(
|
||||
|
||||
title := ""
|
||||
switch notificationType {
|
||||
case databases.NotificationBackupFailed:
|
||||
case backups_config.NotificationBackupFailed:
|
||||
title = fmt.Sprintf("❌ Backup failed for database \"%s\"", database.Name)
|
||||
case databases.NotificationBackupSuccess:
|
||||
case backups_config.NotificationBackupSuccess:
|
||||
title = fmt.Sprintf("✅ Backup completed for database \"%s\"", database.Name)
|
||||
}
|
||||
|
||||
@@ -319,3 +316,45 @@ func (s *BackupService) SendBackupNotification(
|
||||
func (s *BackupService) GetBackup(backupID uuid.UUID) (*Backup, error) {
|
||||
return s.backupRepository.FindByID(backupID)
|
||||
}
|
||||
|
||||
func (s *BackupService) deleteBackup(backup *Backup) error {
|
||||
for _, listener := range s.backupRemoveListeners {
|
||||
if err := listener.OnBeforeBackupRemove(backup); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
backup.DeleteBackupFromStorage(s.logger)
|
||||
|
||||
return s.backupRepository.DeleteByID(backup.ID)
|
||||
}
|
||||
|
||||
func (s *BackupService) deleteDbBackups(databaseID uuid.UUID) error {
|
||||
dbBackupsInProgress, err := s.backupRepository.FindByDatabaseIdAndStatus(
|
||||
databaseID,
|
||||
BackupStatusInProgress,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(dbBackupsInProgress) > 0 {
|
||||
return errors.New("backup is in progress, storage cannot be removed")
|
||||
}
|
||||
|
||||
dbBackups, err := s.backupRepository.FindByDatabaseID(
|
||||
databaseID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, dbBackup := range dbBackups {
|
||||
err := s.deleteBackup(dbBackup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package backups
|
||||
|
||||
import (
|
||||
"errors"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
@@ -20,6 +21,7 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
|
||||
storage := storages.CreateTestStorage(user.UserID)
|
||||
notifier := notifiers.CreateTestNotifier(user.UserID)
|
||||
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
|
||||
backups_config.EnableBackupsForTestDatabase(database.ID, storage)
|
||||
|
||||
defer storages.RemoveTestStorage(storage.ID)
|
||||
defer notifiers.RemoveTestNotifier(notifier)
|
||||
@@ -33,8 +35,10 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
|
||||
backupRepository,
|
||||
notifiers.GetNotifierService(),
|
||||
mockNotificationSender,
|
||||
backups_config.GetBackupConfigService(),
|
||||
&CreateFailedBackupUsecase{},
|
||||
logger.GetLogger(),
|
||||
[]BackupRemoveListener{},
|
||||
}
|
||||
|
||||
// Set up expectations
|
||||
@@ -74,8 +78,10 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
|
||||
backupRepository,
|
||||
notifiers.GetNotifierService(),
|
||||
mockNotificationSender,
|
||||
backups_config.GetBackupConfigService(),
|
||||
&CreateSuccessBackupUsecase{},
|
||||
logger.GetLogger(),
|
||||
[]BackupRemoveListener{},
|
||||
}
|
||||
|
||||
backupService.MakeBackup(database.ID)
|
||||
@@ -92,8 +98,10 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
|
||||
backupRepository,
|
||||
notifiers.GetNotifierService(),
|
||||
mockNotificationSender,
|
||||
backups_config.GetBackupConfigService(),
|
||||
&CreateSuccessBackupUsecase{},
|
||||
logger.GetLogger(),
|
||||
[]BackupRemoveListener{},
|
||||
}
|
||||
|
||||
// capture arguments
|
||||
@@ -130,6 +138,7 @@ type CreateFailedBackupUsecase struct {
|
||||
|
||||
func (uc *CreateFailedBackupUsecase) Execute(
|
||||
backupID uuid.UUID,
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
database *databases.Database,
|
||||
storage *storages.Storage,
|
||||
backupProgressListener func(
|
||||
@@ -145,6 +154,7 @@ type CreateSuccessBackupUsecase struct {
|
||||
|
||||
func (uc *CreateSuccessBackupUsecase) Execute(
|
||||
backupID uuid.UUID,
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
database *databases.Database,
|
||||
storage *storages.Storage,
|
||||
backupProgressListener func(
|
||||
@@ -2,7 +2,8 @@ package usecases
|
||||
|
||||
import (
|
||||
"errors"
|
||||
usecases_postgresql "postgresus-backend/internal/features/backups/usecases/postgresql"
|
||||
usecases_postgresql "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
|
||||
@@ -16,6 +17,7 @@ type CreateBackupUsecase struct {
|
||||
// Execute creates a backup of the database and returns the backup size in MB
|
||||
func (uc *CreateBackupUsecase) Execute(
|
||||
backupID uuid.UUID,
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
database *databases.Database,
|
||||
storage *storages.Storage,
|
||||
backupProgressListener func(
|
||||
@@ -25,6 +27,7 @@ func (uc *CreateBackupUsecase) Execute(
|
||||
if database.Type == databases.DatabaseTypePostgres {
|
||||
return uc.CreatePostgresqlBackupUsecase.Execute(
|
||||
backupID,
|
||||
backupConfig,
|
||||
database,
|
||||
storage,
|
||||
backupProgressListener,
|
||||
@@ -1,7 +1,7 @@
|
||||
package usecases
|
||||
|
||||
import (
|
||||
usecases_postgresql "postgresus-backend/internal/features/backups/usecases/postgresql"
|
||||
usecases_postgresql "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
|
||||
)
|
||||
|
||||
var createBackupUsecase = &CreateBackupUsecase{
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"postgresus-backend/internal/config"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
@@ -29,6 +30,7 @@ type CreatePostgresqlBackupUsecase struct {
|
||||
// Execute creates a backup of the database
|
||||
func (uc *CreatePostgresqlBackupUsecase) Execute(
|
||||
backupID uuid.UUID,
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
db *databases.Database,
|
||||
storage *storages.Storage,
|
||||
backupProgressListener func(
|
||||
@@ -43,6 +45,10 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
|
||||
storage.ID,
|
||||
)
|
||||
|
||||
if !backupConfig.IsBackupsEnabled {
|
||||
return fmt.Errorf("backups are not enabled for this database: \"%s\"", db.Name)
|
||||
}
|
||||
|
||||
pg := db.Postgresql
|
||||
|
||||
if pg == nil {
|
||||
@@ -66,6 +72,7 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
|
||||
|
||||
return uc.streamToStorage(
|
||||
backupID,
|
||||
backupConfig,
|
||||
tools.GetPostgresqlExecutable(
|
||||
pg.Version,
|
||||
"pg_dump",
|
||||
@@ -83,6 +90,7 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
|
||||
// streamToStorage streams pg_dump output directly to storage
|
||||
func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
|
||||
backupID uuid.UUID,
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
pgBin string,
|
||||
args []string,
|
||||
password string,
|
||||
@@ -156,7 +164,7 @@ func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
|
||||
"passwordEmpty", password == "",
|
||||
"pgBin", pgBin,
|
||||
"usingPgpassFile", true,
|
||||
"parallelJobs", db.Postgresql.CpuCount,
|
||||
"parallelJobs", backupConfig.CpuCount,
|
||||
)
|
||||
|
||||
// Add PostgreSQL-specific environment variables
|
||||
144
backend/internal/features/backups/config/controller.go
Normal file
144
backend/internal/features/backups/config/controller.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package backups_config
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"postgresus-backend/internal/features/users"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type BackupConfigController struct {
|
||||
backupConfigService *BackupConfigService
|
||||
userService *users.UserService
|
||||
}
|
||||
|
||||
func (c *BackupConfigController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.POST("/backup-configs/save", c.SaveBackupConfig)
|
||||
router.GET("/backup-configs/database/:id", c.GetBackupConfigByDbID)
|
||||
router.GET("/backup-configs/storage/:id/is-using", c.IsStorageUsing)
|
||||
}
|
||||
|
||||
// SaveBackupConfig
|
||||
// @Summary Save backup configuration
|
||||
// @Description Save or update backup configuration for a database
|
||||
// @Tags backup-configs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body BackupConfig true "Backup configuration data"
|
||||
// @Success 200 {object} BackupConfig
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
// @Failure 500
|
||||
// @Router /backup-configs/save [post]
|
||||
func (c *BackupConfigController) SaveBackupConfig(ctx *gin.Context) {
|
||||
var request BackupConfig
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
authorizationHeader := ctx.GetHeader("Authorization")
|
||||
if authorizationHeader == "" {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err := c.userService.GetUserFromToken(authorizationHeader)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
savedConfig, err := c.backupConfigService.SaveBackupConfig(&request)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, savedConfig)
|
||||
}
|
||||
|
||||
// GetBackupConfigByDbID
|
||||
// @Summary Get backup configuration by database ID
|
||||
// @Description Get backup configuration for a specific database
|
||||
// @Tags backup-configs
|
||||
// @Produce json
|
||||
// @Param id path string true "Database ID"
|
||||
// @Success 200 {object} BackupConfig
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
// @Failure 404
|
||||
// @Router /backup-configs/database/{id} [get]
|
||||
func (c *BackupConfigController) GetBackupConfigByDbID(ctx *gin.Context) {
|
||||
id, err := uuid.Parse(ctx.Param("id"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
|
||||
return
|
||||
}
|
||||
|
||||
authorizationHeader := ctx.GetHeader("Authorization")
|
||||
if authorizationHeader == "" {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = c.userService.GetUserFromToken(authorizationHeader)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := c.userService.GetUserFromToken(authorizationHeader)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
backupConfig, err := c.backupConfigService.GetBackupConfigByDbIdWithAuth(user, id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "backup configuration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, backupConfig)
|
||||
}
|
||||
|
||||
// IsStorageUsing
|
||||
// @Summary Check if storage is being used
|
||||
// @Description Check if a storage is currently being used by any backup configuration
|
||||
// @Tags backup-configs
|
||||
// @Produce json
|
||||
// @Param id path string true "Storage ID"
|
||||
// @Success 200 {object} map[string]bool
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
// @Failure 500
|
||||
// @Router /backup-configs/storage/{id}/is-using [get]
|
||||
func (c *BackupConfigController) IsStorageUsing(ctx *gin.Context) {
|
||||
id, err := uuid.Parse(ctx.Param("id"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid storage ID"})
|
||||
return
|
||||
}
|
||||
|
||||
authorizationHeader := ctx.GetHeader("Authorization")
|
||||
if authorizationHeader == "" {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := c.userService.GetUserFromToken(authorizationHeader)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
isUsing, err := c.backupConfigService.IsStorageUsing(user, id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing})
|
||||
}
|
||||
27
backend/internal/features/backups/config/di.go
Normal file
27
backend/internal/features/backups/config/di.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package backups_config
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"postgresus-backend/internal/features/users"
|
||||
)
|
||||
|
||||
var backupConfigRepository = &BackupConfigRepository{}
|
||||
var backupConfigService = &BackupConfigService{
|
||||
backupConfigRepository,
|
||||
databases.GetDatabaseService(),
|
||||
storages.GetStorageService(),
|
||||
nil,
|
||||
}
|
||||
var backupConfigController = &BackupConfigController{
|
||||
backupConfigService,
|
||||
users.GetUserService(),
|
||||
}
|
||||
|
||||
func GetBackupConfigController() *BackupConfigController {
|
||||
return backupConfigController
|
||||
}
|
||||
|
||||
func GetBackupConfigService() *BackupConfigService {
|
||||
return backupConfigService
|
||||
}
|
||||
8
backend/internal/features/backups/config/enums.go
Normal file
8
backend/internal/features/backups/config/enums.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package backups_config
|
||||
|
||||
type BackupNotificationType string
|
||||
|
||||
const (
|
||||
NotificationBackupFailed BackupNotificationType = "BACKUP_FAILED"
|
||||
NotificationBackupSuccess BackupNotificationType = "BACKUP_SUCCESS"
|
||||
)
|
||||
7
backend/internal/features/backups/config/interfaces.go
Normal file
7
backend/internal/features/backups/config/interfaces.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package backups_config
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type BackupConfigStorageChangeListener interface {
|
||||
OnBeforeBackupsStorageChange(dbID uuid.UUID, storageID uuid.UUID) error
|
||||
}
|
||||
85
backend/internal/features/backups/config/model.go
Normal file
85
backend/internal/features/backups/config/model.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package backups_config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"postgresus-backend/internal/features/intervals"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"postgresus-backend/internal/util/period"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type BackupConfig struct {
|
||||
DatabaseID uuid.UUID `json:"databaseId" gorm:"column:database_id;type:uuid;primaryKey;not null"`
|
||||
|
||||
IsBackupsEnabled bool `json:"isBackupsEnabled" gorm:"column:is_backups_enabled;type:boolean;not null"`
|
||||
|
||||
StorePeriod period.Period `json:"storePeriod" gorm:"column:store_period;type:text;not null"`
|
||||
|
||||
BackupIntervalID uuid.UUID `json:"backupIntervalId" gorm:"column:backup_interval_id;type:uuid;not null"`
|
||||
BackupInterval *intervals.Interval `json:"backupInterval,omitempty" gorm:"foreignKey:BackupIntervalID"`
|
||||
|
||||
Storage *storages.Storage `json:"storage" gorm:"foreignKey:StorageID"`
|
||||
StorageID *uuid.UUID `json:"storageId" gorm:"column:storage_id;type:uuid;"`
|
||||
|
||||
SendNotificationsOn []BackupNotificationType `json:"sendNotificationsOn" gorm:"-"`
|
||||
SendNotificationsOnString string `json:"-" gorm:"column:send_notifications_on;type:text;not null"`
|
||||
|
||||
CpuCount int `json:"cpuCount" gorm:"type:int;not null"`
|
||||
}
|
||||
|
||||
func (h *BackupConfig) TableName() string {
|
||||
return "backup_configs"
|
||||
}
|
||||
|
||||
func (b *BackupConfig) BeforeSave(tx *gorm.DB) error {
|
||||
// Convert SendNotificationsOn array to string
|
||||
if len(b.SendNotificationsOn) > 0 {
|
||||
notificationTypes := make([]string, len(b.SendNotificationsOn))
|
||||
|
||||
for i, notificationType := range b.SendNotificationsOn {
|
||||
notificationTypes[i] = string(notificationType)
|
||||
}
|
||||
|
||||
b.SendNotificationsOnString = strings.Join(notificationTypes, ",")
|
||||
} else {
|
||||
b.SendNotificationsOnString = ""
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BackupConfig) AfterFind(tx *gorm.DB) error {
|
||||
// Convert SendNotificationsOnString to array
|
||||
if b.SendNotificationsOnString != "" {
|
||||
notificationTypes := strings.Split(b.SendNotificationsOnString, ",")
|
||||
b.SendNotificationsOn = make([]BackupNotificationType, len(notificationTypes))
|
||||
|
||||
for i, notificationType := range notificationTypes {
|
||||
b.SendNotificationsOn[i] = BackupNotificationType(notificationType)
|
||||
}
|
||||
} else {
|
||||
b.SendNotificationsOn = []BackupNotificationType{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *BackupConfig) Validate() error {
|
||||
// Backup interval is required either as ID or as object
|
||||
if b.BackupIntervalID == uuid.Nil && b.BackupInterval == nil {
|
||||
return errors.New("backup interval is required")
|
||||
}
|
||||
|
||||
if b.StorePeriod == "" {
|
||||
return errors.New("store period is required")
|
||||
}
|
||||
|
||||
if b.CpuCount == 0 {
|
||||
return errors.New("cpu count is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
104
backend/internal/features/backups/config/repository.go
Normal file
104
backend/internal/features/backups/config/repository.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package backups_config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"postgresus-backend/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type BackupConfigRepository struct{}
|
||||
|
||||
func (r *BackupConfigRepository) Save(
|
||||
backupConfig *BackupConfig,
|
||||
) (*BackupConfig, error) {
|
||||
db := storage.GetDb()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Handle BackupInterval
|
||||
if backupConfig.BackupInterval != nil {
|
||||
if backupConfig.BackupInterval.ID == uuid.Nil {
|
||||
if err := tx.Create(backupConfig.BackupInterval).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
backupConfig.BackupIntervalID = backupConfig.BackupInterval.ID
|
||||
} else {
|
||||
if err := tx.Save(backupConfig.BackupInterval).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
backupConfig.BackupIntervalID = backupConfig.BackupInterval.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Set storage ID
|
||||
if backupConfig.Storage != nil && backupConfig.Storage.ID != uuid.Nil {
|
||||
backupConfig.StorageID = &backupConfig.Storage.ID
|
||||
}
|
||||
|
||||
// Use Save which handles both create and update based on primary key
|
||||
if err := tx.Save(backupConfig).
|
||||
Omit("BackupInterval", "Storage").
|
||||
Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return backupConfig, nil
|
||||
}
|
||||
|
||||
func (r *BackupConfigRepository) FindByDatabaseID(databaseID uuid.UUID) (*BackupConfig, error) {
|
||||
var backupConfig BackupConfig
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Preload("BackupInterval").
|
||||
Preload("Storage").
|
||||
Where("database_id = ?", databaseID).
|
||||
First(&backupConfig).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &backupConfig, nil
|
||||
}
|
||||
|
||||
func (r *BackupConfigRepository) FindWithEnabledBackups() ([]*BackupConfig, error) {
|
||||
var backupConfigs []*BackupConfig
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Preload("BackupInterval").
|
||||
Preload("Storage").
|
||||
Where("is_backups_enabled = ?", true).
|
||||
Find(&backupConfigs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return backupConfigs, nil
|
||||
}
|
||||
|
||||
func (r *BackupConfigRepository) IsStorageUsing(storageID uuid.UUID) (bool, error) {
|
||||
var count int64
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Table("backup_configs").
|
||||
Where("storage_id = ?", storageID).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
167
backend/internal/features/backups/config/service.go
Normal file
167
backend/internal/features/backups/config/service.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package backups_config
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/intervals"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
users_models "postgresus-backend/internal/features/users/models"
|
||||
"postgresus-backend/internal/util/period"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type BackupConfigService struct {
|
||||
backupConfigRepository *BackupConfigRepository
|
||||
databaseService *databases.DatabaseService
|
||||
storageService *storages.StorageService
|
||||
|
||||
dbStorageChangeListener BackupConfigStorageChangeListener
|
||||
}
|
||||
|
||||
func (s *BackupConfigService) SetDatabaseStorageChangeListener(
|
||||
dbStorageChangeListener BackupConfigStorageChangeListener,
|
||||
) {
|
||||
s.dbStorageChangeListener = dbStorageChangeListener
|
||||
}
|
||||
|
||||
func (s *BackupConfigService) SaveBackupConfigWithAuth(
|
||||
user *users_models.User,
|
||||
backupConfig *BackupConfig,
|
||||
) (*BackupConfig, error) {
|
||||
if err := backupConfig.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err := s.databaseService.GetDatabase(user, backupConfig.DatabaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.SaveBackupConfig(backupConfig)
|
||||
}
|
||||
|
||||
func (s *BackupConfigService) SaveBackupConfig(
|
||||
backupConfig *BackupConfig,
|
||||
) (*BackupConfig, error) {
|
||||
if err := backupConfig.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if there's an existing backup config for this database
|
||||
existingConfig, err := s.GetBackupConfigByDbId(backupConfig.DatabaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingConfig != nil {
|
||||
// If storage is changing, notify the listener
|
||||
if s.dbStorageChangeListener != nil &&
|
||||
!storageIDsEqual(existingConfig.StorageID, backupConfig.StorageID) {
|
||||
var newStorageID uuid.UUID
|
||||
|
||||
if backupConfig.StorageID != nil {
|
||||
newStorageID = *backupConfig.StorageID
|
||||
}
|
||||
|
||||
if err := s.dbStorageChangeListener.OnBeforeBackupsStorageChange(
|
||||
backupConfig.DatabaseID,
|
||||
newStorageID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !backupConfig.IsBackupsEnabled && backupConfig.StorageID != nil {
|
||||
if err := s.dbStorageChangeListener.OnBeforeBackupsStorageChange(
|
||||
backupConfig.DatabaseID,
|
||||
*backupConfig.StorageID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// we clear storage for disabled backups to allow
|
||||
// storage removal for unused storages
|
||||
backupConfig.Storage = nil
|
||||
backupConfig.StorageID = nil
|
||||
}
|
||||
|
||||
return s.backupConfigRepository.Save(backupConfig)
|
||||
}
|
||||
|
||||
func (s *BackupConfigService) GetBackupConfigByDbIdWithAuth(
|
||||
user *users_models.User,
|
||||
databaseID uuid.UUID,
|
||||
) (*BackupConfig, error) {
|
||||
_, err := s.databaseService.GetDatabase(user, databaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetBackupConfigByDbId(databaseID)
|
||||
}
|
||||
|
||||
func (s *BackupConfigService) GetBackupConfigByDbId(
|
||||
databaseID uuid.UUID,
|
||||
) (*BackupConfig, error) {
|
||||
config, err := s.backupConfigRepository.FindByDatabaseID(databaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if config == nil {
|
||||
err = s.initializeDefaultConfig(databaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.backupConfigRepository.FindByDatabaseID(databaseID)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (s *BackupConfigService) IsStorageUsing(
|
||||
user *users_models.User,
|
||||
storageID uuid.UUID,
|
||||
) (bool, error) {
|
||||
_, err := s.storageService.GetStorage(user, storageID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return s.backupConfigRepository.IsStorageUsing(storageID)
|
||||
}
|
||||
|
||||
func (s *BackupConfigService) initializeDefaultConfig(
|
||||
databaseID uuid.UUID,
|
||||
) error {
|
||||
timeOfDay := "04:00"
|
||||
|
||||
_, err := s.backupConfigRepository.Save(&BackupConfig{
|
||||
DatabaseID: databaseID,
|
||||
IsBackupsEnabled: false,
|
||||
StorePeriod: period.PeriodWeek,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
NotificationBackupSuccess,
|
||||
},
|
||||
CpuCount: 1,
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func storageIDsEqual(id1, id2 *uuid.UUID) bool {
|
||||
if id1 == nil && id2 == nil {
|
||||
return true
|
||||
}
|
||||
if id1 == nil || id2 == nil {
|
||||
return false
|
||||
}
|
||||
return *id1 == *id2
|
||||
}
|
||||
40
backend/internal/features/backups/config/testing.go
Normal file
40
backend/internal/features/backups/config/testing.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package backups_config
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/intervals"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"postgresus-backend/internal/util/period"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func EnableBackupsForTestDatabase(
|
||||
databaseID uuid.UUID,
|
||||
storage *storages.Storage,
|
||||
) *BackupConfig {
|
||||
timeOfDay := "16:00"
|
||||
|
||||
backupConfig := &BackupConfig{
|
||||
DatabaseID: databaseID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodDay,
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
StorageID: &storage.ID,
|
||||
Storage: storage,
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
NotificationBackupSuccess,
|
||||
},
|
||||
CpuCount: 1,
|
||||
}
|
||||
|
||||
backupConfig, err := GetBackupConfigService().SaveBackupConfig(backupConfig)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return backupConfig
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.POST("/databases/:id/test-connection", c.TestDatabaseConnection)
|
||||
router.POST("/databases/test-connection-direct", c.TestDatabaseConnectionDirect)
|
||||
router.GET("/databases/notifier/:id/is-using", c.IsNotifierUsing)
|
||||
router.GET("/databases/storage/:id/is-using", c.IsStorageUsing)
|
||||
|
||||
}
|
||||
|
||||
// CreateDatabase
|
||||
@@ -56,12 +56,13 @@ func (c *DatabaseController) CreateDatabase(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.databaseService.CreateDatabase(user, &request); err != nil {
|
||||
database, err := c.databaseService.CreateDatabase(user, &request)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, request)
|
||||
ctx.JSON(http.StatusCreated, database)
|
||||
}
|
||||
|
||||
// UpdateDatabase
|
||||
@@ -310,52 +311,13 @@ func (c *DatabaseController) IsNotifierUsing(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = c.userService.GetUserFromToken(authorizationHeader)
|
||||
user, err := c.userService.GetUserFromToken(authorizationHeader)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
isUsing, err := c.databaseService.IsNotifierUsing(id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing})
|
||||
}
|
||||
|
||||
// IsStorageUsing
|
||||
// @Summary Check if storage is being used
|
||||
// @Description Check if a storage is currently being used by any database
|
||||
// @Tags databases
|
||||
// @Produce json
|
||||
// @Param id path string true "Storage ID"
|
||||
// @Success 200 {object} map[string]bool
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
// @Failure 500
|
||||
// @Router /databases/storage/{id}/is-using [get]
|
||||
func (c *DatabaseController) IsStorageUsing(ctx *gin.Context) {
|
||||
id, err := uuid.Parse(ctx.Param("id"))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid storage ID"})
|
||||
return
|
||||
}
|
||||
|
||||
authorizationHeader := ctx.GetHeader("Authorization")
|
||||
if authorizationHeader == "" {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = c.userService.GetUserFromToken(authorizationHeader)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
isUsing, err := c.databaseService.IsStorageUsing(id)
|
||||
isUsing, err := c.databaseService.IsNotifierUsing(user, id)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -27,8 +27,6 @@ type PostgresqlDatabase struct {
|
||||
Password string `json:"password" gorm:"type:text;not null"`
|
||||
Database *string `json:"database" gorm:"type:text"`
|
||||
IsHttps bool `json:"isHttps" gorm:"type:boolean;default:false"`
|
||||
|
||||
CpuCount int `json:"cpuCount" gorm:"type:int;not null"`
|
||||
}
|
||||
|
||||
func (p *PostgresqlDatabase) TableName() string {
|
||||
|
||||
@@ -12,8 +12,8 @@ var databaseService = &DatabaseService{
|
||||
databaseRepository,
|
||||
notifiers.GetNotifierService(),
|
||||
logger.GetLogger(),
|
||||
nil,
|
||||
nil,
|
||||
[]DatabaseCreationListener{},
|
||||
[]DatabaseRemoveListener{},
|
||||
}
|
||||
|
||||
var databaseController = &DatabaseController{
|
||||
|
||||
@@ -1,66 +1,11 @@
|
||||
package databases
|
||||
|
||||
import "time"
|
||||
|
||||
type DatabaseType string
|
||||
|
||||
const (
|
||||
DatabaseTypePostgres DatabaseType = "POSTGRES"
|
||||
)
|
||||
|
||||
type Period string
|
||||
|
||||
const (
|
||||
PeriodDay Period = "DAY"
|
||||
PeriodWeek Period = "WEEK"
|
||||
PeriodMonth Period = "MONTH"
|
||||
Period3Month Period = "3_MONTH"
|
||||
Period6Month Period = "6_MONTH"
|
||||
PeriodYear Period = "YEAR"
|
||||
Period2Years Period = "2_YEARS"
|
||||
Period3Years Period = "3_YEARS"
|
||||
Period4Years Period = "4_YEARS"
|
||||
Period5Years Period = "5_YEARS"
|
||||
PeriodForever Period = "FOREVER"
|
||||
)
|
||||
|
||||
// ToDuration converts Period to time.Duration
|
||||
func (p Period) ToDuration() time.Duration {
|
||||
switch p {
|
||||
case PeriodDay:
|
||||
return 24 * time.Hour
|
||||
case PeriodWeek:
|
||||
return 7 * 24 * time.Hour
|
||||
case PeriodMonth:
|
||||
return 30 * 24 * time.Hour
|
||||
case Period3Month:
|
||||
return 90 * 24 * time.Hour
|
||||
case Period6Month:
|
||||
return 180 * 24 * time.Hour
|
||||
case PeriodYear:
|
||||
return 365 * 24 * time.Hour
|
||||
case Period2Years:
|
||||
return 2 * 365 * 24 * time.Hour
|
||||
case Period3Years:
|
||||
return 3 * 365 * 24 * time.Hour
|
||||
case Period4Years:
|
||||
return 4 * 365 * 24 * time.Hour
|
||||
case Period5Years:
|
||||
return 5 * 365 * 24 * time.Hour
|
||||
case PeriodForever:
|
||||
return 0
|
||||
default:
|
||||
panic("unknown period: " + string(p))
|
||||
}
|
||||
}
|
||||
|
||||
type BackupNotificationType string
|
||||
|
||||
const (
|
||||
NotificationBackupFailed BackupNotificationType = "BACKUP_FAILED"
|
||||
NotificationBackupSuccess BackupNotificationType = "BACKUP_SUCCESS"
|
||||
)
|
||||
|
||||
type HealthStatus string
|
||||
|
||||
const (
|
||||
|
||||
@@ -14,10 +14,10 @@ type DatabaseConnector interface {
|
||||
TestConnection(logger *slog.Logger) error
|
||||
}
|
||||
|
||||
type DatabaseStorageChangeListener interface {
|
||||
OnBeforeDbStorageChange(dbID uuid.UUID, storageID uuid.UUID) error
|
||||
}
|
||||
|
||||
type DatabaseCreationListener interface {
|
||||
OnDatabaseCreated(databaseID uuid.UUID)
|
||||
}
|
||||
|
||||
type DatabaseRemoveListener interface {
|
||||
OnBeforeDatabaseRemove(databaseID uuid.UUID) error
|
||||
}
|
||||
|
||||
@@ -4,34 +4,21 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/intervals"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"`
|
||||
UserID uuid.UUID `json:"userId" gorm:"column:user_id;type:uuid;not null"`
|
||||
Name string `json:"name" gorm:"column:name;type:text;not null"`
|
||||
Type DatabaseType `json:"type" gorm:"column:type;type:text;not null"`
|
||||
StorePeriod Period `json:"storePeriod" gorm:"column:store_period;type:text;not null"`
|
||||
|
||||
BackupIntervalID uuid.UUID `json:"backupIntervalId" gorm:"column:backup_interval_id;type:uuid;not null"`
|
||||
BackupInterval *intervals.Interval `json:"backupInterval,omitempty" gorm:"foreignKey:BackupIntervalID"`
|
||||
ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"`
|
||||
UserID uuid.UUID `json:"userId" gorm:"column:user_id;type:uuid;not null"`
|
||||
Name string `json:"name" gorm:"column:name;type:text;not null"`
|
||||
Type DatabaseType `json:"type" gorm:"column:type;type:text;not null"`
|
||||
|
||||
Postgresql *postgresql.PostgresqlDatabase `json:"postgresql,omitempty" gorm:"foreignKey:DatabaseID"`
|
||||
|
||||
Storage storages.Storage `json:"storage" gorm:"foreignKey:StorageID"`
|
||||
StorageID uuid.UUID `json:"storageId" gorm:"column:storage_id;type:uuid;not null"`
|
||||
|
||||
Notifiers []notifiers.Notifier `json:"notifiers" gorm:"many2many:database_notifiers;"`
|
||||
SendNotificationsOn []BackupNotificationType `json:"sendNotificationsOn" gorm:"-"`
|
||||
SendNotificationsOnString string `json:"-" gorm:"column:send_notifications_on;type:text;not null"`
|
||||
Notifiers []notifiers.Notifier `json:"notifiers" gorm:"many2many:database_notifiers;"`
|
||||
|
||||
// these fields are not reliable, but
|
||||
// they are used for pretty UI
|
||||
@@ -41,56 +28,11 @@ type Database struct {
|
||||
HealthStatus *HealthStatus `json:"healthStatus" gorm:"column:health_status;type:text;not null"`
|
||||
}
|
||||
|
||||
func (d *Database) BeforeSave(tx *gorm.DB) error {
|
||||
// Convert SendNotificationsOn array to string
|
||||
if len(d.SendNotificationsOn) > 0 {
|
||||
notificationTypes := make([]string, len(d.SendNotificationsOn))
|
||||
|
||||
for i, notificationType := range d.SendNotificationsOn {
|
||||
notificationTypes[i] = string(notificationType)
|
||||
}
|
||||
|
||||
d.SendNotificationsOnString = strings.Join(notificationTypes, ",")
|
||||
} else {
|
||||
d.SendNotificationsOnString = ""
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) AfterFind(tx *gorm.DB) error {
|
||||
// Convert SendNotificationsOnString to array
|
||||
if d.SendNotificationsOnString != "" {
|
||||
notificationTypes := strings.Split(d.SendNotificationsOnString, ",")
|
||||
d.SendNotificationsOn = make([]BackupNotificationType, len(notificationTypes))
|
||||
for i, notificationType := range notificationTypes {
|
||||
d.SendNotificationsOn[i] = BackupNotificationType(notificationType)
|
||||
}
|
||||
} else {
|
||||
d.SendNotificationsOn = []BackupNotificationType{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Validate() error {
|
||||
if d.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
|
||||
// Backup interval is required either as ID or as object
|
||||
if d.BackupIntervalID == uuid.Nil && d.BackupInterval == nil {
|
||||
return errors.New("backup interval is required")
|
||||
}
|
||||
|
||||
if d.StorePeriod == "" {
|
||||
return errors.New("store period is required")
|
||||
}
|
||||
|
||||
if d.Postgresql.CpuCount == 0 {
|
||||
return errors.New("cpu count is required")
|
||||
}
|
||||
|
||||
switch d.Type {
|
||||
case DatabaseTypePostgres:
|
||||
return d.Postgresql.Validate()
|
||||
|
||||
@@ -18,25 +18,7 @@ func (r *DatabaseRepository) Save(database *Database) (*Database, error) {
|
||||
database.ID = uuid.New()
|
||||
}
|
||||
|
||||
database.StorageID = database.Storage.ID
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if database.BackupInterval != nil {
|
||||
if database.BackupInterval.ID == uuid.Nil {
|
||||
if err := tx.Create(database.BackupInterval).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
database.BackupIntervalID = database.BackupInterval.ID
|
||||
} else {
|
||||
if err := tx.Save(database.BackupInterval).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
database.BackupIntervalID = database.BackupInterval.ID
|
||||
}
|
||||
}
|
||||
|
||||
switch database.Type {
|
||||
case DatabaseTypePostgres:
|
||||
if database.Postgresql != nil {
|
||||
@@ -46,13 +28,13 @@ func (r *DatabaseRepository) Save(database *Database) (*Database, error) {
|
||||
|
||||
if isNew {
|
||||
if err := tx.Create(database).
|
||||
Omit("Postgresql", "Storage", "Notifiers", "BackupInterval").
|
||||
Omit("Postgresql", "Notifiers").
|
||||
Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Save(database).
|
||||
Omit("Postgresql", "Storage", "Notifiers", "BackupInterval").
|
||||
Omit("Postgresql", "Notifiers").
|
||||
Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -76,7 +58,10 @@ func (r *DatabaseRepository) Save(database *Database) (*Database, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(database).Association("Notifiers").Replace(database.Notifiers); err != nil {
|
||||
if err := tx.
|
||||
Model(database).
|
||||
Association("Notifiers").
|
||||
Replace(database.Notifiers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -95,9 +80,7 @@ func (r *DatabaseRepository) FindByID(id uuid.UUID) (*Database, error) {
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Preload("BackupInterval").
|
||||
Preload("Postgresql").
|
||||
Preload("Storage").
|
||||
Preload("Notifiers").
|
||||
Where("id = ?", id).
|
||||
First(&database).Error; err != nil {
|
||||
@@ -112,9 +95,7 @@ func (r *DatabaseRepository) FindByUserID(userID uuid.UUID) ([]*Database, error)
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Preload("BackupInterval").
|
||||
Preload("Postgresql").
|
||||
Preload("Storage").
|
||||
Preload("Notifiers").
|
||||
Where("user_id = ?", userID).
|
||||
Order("CASE WHEN health_status = 'UNAVAILABLE' THEN 1 WHEN health_status = 'AVAILABLE' THEN 2 WHEN health_status IS NULL THEN 3 ELSE 4 END, name ASC").
|
||||
@@ -140,7 +121,9 @@ func (r *DatabaseRepository) Delete(id uuid.UUID) error {
|
||||
|
||||
switch database.Type {
|
||||
case DatabaseTypePostgres:
|
||||
if err := tx.Where("database_id = ?", id).Delete(&postgresql.PostgresqlDatabase{}).Error; err != nil {
|
||||
if err := tx.
|
||||
Where("database_id = ?", id).
|
||||
Delete(&postgresql.PostgresqlDatabase{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -167,28 +150,12 @@ func (r *DatabaseRepository) IsNotifierUsing(notifierID uuid.UUID) (bool, error)
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *DatabaseRepository) IsStorageUsing(storageID uuid.UUID) (bool, error) {
|
||||
var count int64
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Table("databases").
|
||||
Where("storage_id = ?", storageID).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *DatabaseRepository) GetAllDatabases() ([]*Database, error) {
|
||||
var databases []*Database
|
||||
|
||||
if err := storage.
|
||||
GetDb().
|
||||
Preload("BackupInterval").
|
||||
Preload("Postgresql").
|
||||
Preload("Storage").
|
||||
Preload("Notifiers").
|
||||
Find(&databases).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,14 +15,8 @@ type DatabaseService struct {
|
||||
notifierService *notifiers.NotifierService
|
||||
logger *slog.Logger
|
||||
|
||||
dbStorageChangeListener DatabaseStorageChangeListener
|
||||
dbCreationListener []DatabaseCreationListener
|
||||
}
|
||||
|
||||
func (s *DatabaseService) SetDatabaseStorageChangeListener(
|
||||
dbStorageChangeListener DatabaseStorageChangeListener,
|
||||
) {
|
||||
s.dbStorageChangeListener = dbStorageChangeListener
|
||||
dbCreationListener []DatabaseCreationListener
|
||||
dbRemoveListener []DatabaseRemoveListener
|
||||
}
|
||||
|
||||
func (s *DatabaseService) AddDbCreationListener(
|
||||
@@ -31,26 +25,32 @@ func (s *DatabaseService) AddDbCreationListener(
|
||||
s.dbCreationListener = append(s.dbCreationListener, dbCreationListener)
|
||||
}
|
||||
|
||||
func (s *DatabaseService) AddDbRemoveListener(
|
||||
dbRemoveListener DatabaseRemoveListener,
|
||||
) {
|
||||
s.dbRemoveListener = append(s.dbRemoveListener, dbRemoveListener)
|
||||
}
|
||||
|
||||
func (s *DatabaseService) CreateDatabase(
|
||||
user *users_models.User,
|
||||
database *Database,
|
||||
) error {
|
||||
) (*Database, error) {
|
||||
database.UserID = user.ID
|
||||
|
||||
if err := database.Validate(); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
database, err := s.dbRepository.Save(database)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, listener := range s.dbCreationListener {
|
||||
listener.OnDatabaseCreated(database.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func (s *DatabaseService) UpdateDatabase(
|
||||
@@ -79,17 +79,6 @@ func (s *DatabaseService) UpdateDatabase(
|
||||
return err
|
||||
}
|
||||
|
||||
if existingDatabase.Storage.ID != database.Storage.ID {
|
||||
err := s.dbStorageChangeListener.OnBeforeDbStorageChange(
|
||||
existingDatabase.ID,
|
||||
database.StorageID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.dbRepository.Save(database)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -111,6 +100,12 @@ func (s *DatabaseService) DeleteDatabase(
|
||||
return errors.New("you have not access to this database")
|
||||
}
|
||||
|
||||
for _, listener := range s.dbRemoveListener {
|
||||
if err := listener.OnBeforeDatabaseRemove(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.dbRepository.Delete(id)
|
||||
}
|
||||
|
||||
@@ -136,12 +131,16 @@ func (s *DatabaseService) GetDatabasesByUser(
|
||||
return s.dbRepository.FindByUserID(user.ID)
|
||||
}
|
||||
|
||||
func (s *DatabaseService) IsNotifierUsing(notifierID uuid.UUID) (bool, error) {
|
||||
return s.dbRepository.IsNotifierUsing(notifierID)
|
||||
}
|
||||
func (s *DatabaseService) IsNotifierUsing(
|
||||
user *users_models.User,
|
||||
notifierID uuid.UUID,
|
||||
) (bool, error) {
|
||||
_, err := s.notifierService.GetNotifier(user, notifierID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (s *DatabaseService) IsStorageUsing(storageID uuid.UUID) (bool, error) {
|
||||
return s.dbRepository.IsStorageUsing(storageID)
|
||||
return s.dbRepository.IsNotifierUsing(notifierID)
|
||||
}
|
||||
|
||||
func (s *DatabaseService) TestDatabaseConnection(
|
||||
|
||||
@@ -2,7 +2,6 @@ package databases
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/intervals"
|
||||
"postgresus-backend/internal/features/notifiers"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
@@ -15,18 +14,10 @@ func CreateTestDatabase(
|
||||
storage *storages.Storage,
|
||||
notifier *notifiers.Notifier,
|
||||
) *Database {
|
||||
timeOfDay := "16:00"
|
||||
|
||||
database := &Database{
|
||||
UserID: userID,
|
||||
Name: "test " + uuid.New().String(),
|
||||
Type: DatabaseTypePostgres,
|
||||
StorePeriod: PeriodDay,
|
||||
|
||||
BackupInterval: &intervals.Interval{
|
||||
Interval: intervals.IntervalDaily,
|
||||
TimeOfDay: &timeOfDay,
|
||||
},
|
||||
UserID: userID,
|
||||
Name: "test " + uuid.New().String(),
|
||||
Type: DatabaseTypePostgres,
|
||||
|
||||
Postgresql: &postgresql.PostgresqlDatabase{
|
||||
Version: tools.PostgresqlVersion16,
|
||||
@@ -36,16 +27,9 @@ func CreateTestDatabase(
|
||||
Password: "postgres",
|
||||
},
|
||||
|
||||
StorageID: storage.ID,
|
||||
Storage: *storage,
|
||||
|
||||
Notifiers: []notifiers.Notifier{
|
||||
*notifier,
|
||||
},
|
||||
SendNotificationsOn: []BackupNotificationType{
|
||||
NotificationBackupFailed,
|
||||
NotificationBackupSuccess,
|
||||
},
|
||||
}
|
||||
|
||||
database, err := databaseRepository.Save(database)
|
||||
|
||||
@@ -83,7 +83,6 @@ func (uc *CheckPgHealthUseCase) updateDatabaseHealthStatusIfChanged(
|
||||
heathcheckAttempt *HealthcheckAttempt,
|
||||
) error {
|
||||
if &heathcheckAttempt.Status == database.HealthStatus {
|
||||
fmt.Println("Database health status is the same as the attempt status")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -226,7 +225,7 @@ func (uc *CheckPgHealthUseCase) sendDbStatusNotification(
|
||||
|
||||
if newHealthStatus == databases.HealthStatusAvailable {
|
||||
messageTitle = fmt.Sprintf("✅ [%s] DB is back online", database.Name)
|
||||
messageBody = fmt.Sprintf("✅ [%s] DB is back online after being unavailable", database.Name)
|
||||
messageBody = fmt.Sprintf("✅ [%s] DB is back online", database.Name)
|
||||
} else {
|
||||
messageTitle = fmt.Sprintf("❌ [%s] DB is unavailable", database.Name)
|
||||
messageBody = fmt.Sprintf("❌ [%s] DB is currently unavailable", database.Name)
|
||||
|
||||
@@ -85,8 +85,8 @@ func Test_CheckPgHealthUseCase(t *testing.T) {
|
||||
t,
|
||||
"SendNotification",
|
||||
mock.Anything,
|
||||
fmt.Sprintf("❌ DB [%s] is unavailable", database.Name),
|
||||
fmt.Sprintf("❌ The [%s] database is currently unavailable", database.Name),
|
||||
fmt.Sprintf("❌ [%s] DB is unavailable", database.Name),
|
||||
fmt.Sprintf("❌ [%s] DB is currently unavailable", database.Name),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -150,8 +150,8 @@ func Test_CheckPgHealthUseCase(t *testing.T) {
|
||||
t,
|
||||
"SendNotification",
|
||||
mock.Anything,
|
||||
fmt.Sprintf("❌ DB [%s] is unavailable", database.Name),
|
||||
fmt.Sprintf("❌ The [%s] database is currently unavailable", database.Name),
|
||||
fmt.Sprintf("❌ [%s] DB is unavailable", database.Name),
|
||||
fmt.Sprintf("❌ [%s] DB is currently unavailable", database.Name),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -230,7 +230,7 @@ func Test_CheckPgHealthUseCase(t *testing.T) {
|
||||
"SendNotification",
|
||||
mock.Anything,
|
||||
fmt.Sprintf("❌ [%s] DB is unavailable", database.Name),
|
||||
fmt.Sprintf("❌ [%s] Database is currently unavailable", database.Name),
|
||||
fmt.Sprintf("❌ [%s] DB is currently unavailable", database.Name),
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -302,8 +302,8 @@ func Test_CheckPgHealthUseCase(t *testing.T) {
|
||||
t,
|
||||
"SendNotification",
|
||||
mock.Anything,
|
||||
fmt.Sprintf("✅ DB [%s] is back online", database.Name),
|
||||
fmt.Sprintf("✅ The [%s] database is back online after being unavailable", database.Name),
|
||||
fmt.Sprintf("✅ [%s] DB is back online", database.Name),
|
||||
fmt.Sprintf("✅ [%s] DB is back online", database.Name),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package restores
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/backups"
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/restores/usecases"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
"postgresus-backend/internal/features/users"
|
||||
@@ -13,6 +14,7 @@ var restoreService = &RestoreService{
|
||||
backups.GetBackupService(),
|
||||
restoreRepository,
|
||||
storages.GetStorageService(),
|
||||
backups_config.GetBackupConfigService(),
|
||||
usecases.GetRestoreBackupUsecase(),
|
||||
logger.GetLogger(),
|
||||
}
|
||||
@@ -33,3 +35,7 @@ func GetRestoreController() *RestoreController {
|
||||
func GetRestoreBackgroundService() *RestoreBackgroundService {
|
||||
return restoreBackgroundService
|
||||
}
|
||||
|
||||
func SetupDependencies() {
|
||||
backups.GetBackupService().AddBackupRemoveListener(restoreService)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/backups"
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
"postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/restores/enums"
|
||||
"time"
|
||||
|
||||
@@ -3,7 +3,8 @@ package restores
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"postgresus-backend/internal/features/backups"
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/restores/enums"
|
||||
"postgresus-backend/internal/features/restores/models"
|
||||
@@ -19,10 +20,32 @@ type RestoreService struct {
|
||||
backupService *backups.BackupService
|
||||
restoreRepository *RestoreRepository
|
||||
storageService *storages.StorageService
|
||||
backupConfigService *backups_config.BackupConfigService
|
||||
restoreBackupUsecase *usecases.RestoreBackupUsecase
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (s *RestoreService) OnBeforeBackupRemove(backup *backups.Backup) error {
|
||||
restores, err := s.restoreRepository.FindByBackupID(backup.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, restore := range restores {
|
||||
if restore.Status == enums.RestoreStatusInProgress {
|
||||
return errors.New("restore is in progress, backup cannot be removed")
|
||||
}
|
||||
}
|
||||
|
||||
for _, restore := range restores {
|
||||
if err := s.restoreRepository.DeleteByID(restore.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RestoreService) GetRestores(
|
||||
user *users_models.User,
|
||||
backupID uuid.UUID,
|
||||
@@ -110,9 +133,17 @@ func (s *RestoreService) RestoreBackup(
|
||||
return err
|
||||
}
|
||||
|
||||
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(
|
||||
backup.Database.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
start := time.Now().UTC()
|
||||
|
||||
err = s.restoreBackupUsecase.Execute(
|
||||
backupConfig,
|
||||
restore,
|
||||
backup,
|
||||
storage,
|
||||
|
||||
@@ -14,7 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"postgresus-backend/internal/config"
|
||||
"postgresus-backend/internal/features/backups"
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/restores/models"
|
||||
@@ -29,6 +30,7 @@ type RestorePostgresqlBackupUsecase struct {
|
||||
}
|
||||
|
||||
func (uc *RestorePostgresqlBackupUsecase) Execute(
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
restore models.Restore,
|
||||
backup *backups.Backup,
|
||||
storage *storages.Storage,
|
||||
@@ -56,7 +58,7 @@ func (uc *RestorePostgresqlBackupUsecase) Execute(
|
||||
|
||||
// Use parallel jobs based on CPU count (same as backup)
|
||||
// Cap between 1 and 8 to avoid overwhelming the server
|
||||
parallelJobs := max(1, min(pg.CpuCount, 8))
|
||||
parallelJobs := max(1, min(backupConfig.CpuCount, 8))
|
||||
|
||||
args := []string{
|
||||
"-Fc", // expect custom format (same as backup)
|
||||
|
||||
@@ -2,7 +2,8 @@ package usecases
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"postgresus-backend/internal/features/backups"
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
"postgresus-backend/internal/features/restores/models"
|
||||
usecases_postgresql "postgresus-backend/internal/features/restores/usecases/postgresql"
|
||||
@@ -14,12 +15,18 @@ type RestoreBackupUsecase struct {
|
||||
}
|
||||
|
||||
func (uc *RestoreBackupUsecase) Execute(
|
||||
backupConfig *backups_config.BackupConfig,
|
||||
restore models.Restore,
|
||||
backup *backups.Backup,
|
||||
storage *storages.Storage,
|
||||
) error {
|
||||
if restore.Backup.Database.Type == databases.DatabaseTypePostgres {
|
||||
return uc.restorePostgresqlBackupUsecase.Execute(restore, backup, storage)
|
||||
return uc.restorePostgresqlBackupUsecase.Execute(
|
||||
backupConfig,
|
||||
restore,
|
||||
backup,
|
||||
storage,
|
||||
)
|
||||
}
|
||||
|
||||
return errors.New("database type not supported")
|
||||
|
||||
@@ -38,17 +38,32 @@ func (s *GoogleDriveStorage) SaveFile(
|
||||
ctx := context.Background()
|
||||
filename := fileID.String()
|
||||
|
||||
// Ensure the postgresus_backups folder exists
|
||||
folderID, err := s.ensureBackupsFolderExists(ctx, driveService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create/find backups folder: %w", err)
|
||||
}
|
||||
|
||||
// Delete any previous copy so we keep at most one object per logical file.
|
||||
_ = s.deleteByName(ctx, driveService, filename) // ignore "not found"
|
||||
_ = s.deleteByName(ctx, driveService, filename, folderID) // ignore "not found"
|
||||
|
||||
fileMeta := &drive.File{Name: filename}
|
||||
fileMeta := &drive.File{
|
||||
Name: filename,
|
||||
Parents: []string{folderID},
|
||||
}
|
||||
|
||||
_, err := driveService.Files.Create(fileMeta).Media(file).Context(ctx).Do()
|
||||
_, err = driveService.Files.Create(fileMeta).Media(file).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload file to Google Drive: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("file uploaded to Google Drive", "name", filename)
|
||||
logger.Info(
|
||||
"file uploaded to Google Drive",
|
||||
"name",
|
||||
filename,
|
||||
"folder",
|
||||
"postgresus_backups",
|
||||
)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -56,7 +71,12 @@ func (s *GoogleDriveStorage) SaveFile(
|
||||
func (s *GoogleDriveStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
var result io.ReadCloser
|
||||
err := s.withRetryOnAuth(func(driveService *drive.Service) error {
|
||||
fileIDGoogle, err := s.lookupFileID(driveService, fileID.String())
|
||||
folderID, err := s.findBackupsFolder(driveService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find backups folder: %w", err)
|
||||
}
|
||||
|
||||
fileIDGoogle, err := s.lookupFileID(driveService, fileID.String(), folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -76,7 +96,12 @@ func (s *GoogleDriveStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
|
||||
func (s *GoogleDriveStorage) DeleteFile(fileID uuid.UUID) error {
|
||||
return s.withRetryOnAuth(func(driveService *drive.Service) error {
|
||||
ctx := context.Background()
|
||||
return s.deleteByName(ctx, driveService, fileID.String())
|
||||
folderID, err := s.findBackupsFolder(driveService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find backups folder: %w", err)
|
||||
}
|
||||
|
||||
return s.deleteByName(ctx, driveService, fileID.String(), folderID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,8 +134,17 @@ func (s *GoogleDriveStorage) TestConnection() error {
|
||||
testFilename := "test-connection-" + uuid.New().String()
|
||||
testData := []byte("test")
|
||||
|
||||
// Ensure the postgresus_backups folder exists
|
||||
folderID, err := s.ensureBackupsFolderExists(ctx, driveService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create/find backups folder: %w", err)
|
||||
}
|
||||
|
||||
// Test write operation
|
||||
fileMeta := &drive.File{Name: testFilename}
|
||||
fileMeta := &drive.File{
|
||||
Name: testFilename,
|
||||
Parents: []string{folderID},
|
||||
}
|
||||
file, err := driveService.Files.Create(fileMeta).
|
||||
Media(strings.NewReader(string(testData))).
|
||||
Context(ctx).
|
||||
@@ -358,8 +392,13 @@ func (s *GoogleDriveStorage) getDriveService() (*drive.Service, error) {
|
||||
func (s *GoogleDriveStorage) lookupFileID(
|
||||
driveService *drive.Service,
|
||||
name string,
|
||||
folderID string,
|
||||
) (string, error) {
|
||||
query := fmt.Sprintf("name = '%s' and trashed = false", escapeForQuery(name))
|
||||
query := fmt.Sprintf(
|
||||
"name = '%s' and trashed = false and '%s' in parents",
|
||||
escapeForQuery(name),
|
||||
folderID,
|
||||
)
|
||||
|
||||
results, err := driveService.Files.List().
|
||||
Q(query).
|
||||
@@ -371,7 +410,7 @@ func (s *GoogleDriveStorage) lookupFileID(
|
||||
}
|
||||
|
||||
if len(results.Files) == 0 {
|
||||
return "", fmt.Errorf("file %q not found in Google Drive", name)
|
||||
return "", fmt.Errorf("file %q not found in Google Drive backups folder", name)
|
||||
}
|
||||
|
||||
return results.Files[0].Id, nil
|
||||
@@ -381,8 +420,13 @@ func (s *GoogleDriveStorage) deleteByName(
|
||||
ctx context.Context,
|
||||
driveService *drive.Service,
|
||||
name string,
|
||||
folderID string,
|
||||
) error {
|
||||
query := fmt.Sprintf("name = '%s' and trashed = false", escapeForQuery(name))
|
||||
query := fmt.Sprintf(
|
||||
"name = '%s' and trashed = false and '%s' in parents",
|
||||
escapeForQuery(name),
|
||||
folderID,
|
||||
)
|
||||
|
||||
err := driveService.
|
||||
Files.
|
||||
@@ -409,3 +453,47 @@ func (s *GoogleDriveStorage) deleteByName(
|
||||
func escapeForQuery(s string) string {
|
||||
return strings.ReplaceAll(s, `'`, `\'`)
|
||||
}
|
||||
|
||||
// ensureBackupsFolderExists creates the postgresus_backups folder if it doesn't exist
|
||||
func (s *GoogleDriveStorage) ensureBackupsFolderExists(
|
||||
ctx context.Context,
|
||||
driveService *drive.Service,
|
||||
) (string, error) {
|
||||
folderID, err := s.findBackupsFolder(driveService)
|
||||
if err == nil {
|
||||
return folderID, nil
|
||||
}
|
||||
|
||||
// Folder doesn't exist, create it
|
||||
folderMeta := &drive.File{
|
||||
Name: "postgresus_backups",
|
||||
MimeType: "application/vnd.google-apps.folder",
|
||||
}
|
||||
|
||||
folder, err := driveService.Files.Create(folderMeta).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create postgresus_backups folder: %w", err)
|
||||
}
|
||||
|
||||
return folder.Id, nil
|
||||
}
|
||||
|
||||
// findBackupsFolder finds the postgresus_backups folder ID
|
||||
func (s *GoogleDriveStorage) findBackupsFolder(driveService *drive.Service) (string, error) {
|
||||
query := "name = 'postgresus_backups' and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
|
||||
|
||||
results, err := driveService.Files.List().
|
||||
Q(query).
|
||||
Fields("files(id)").
|
||||
PageSize(1).
|
||||
Do()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to search for backups folder: %w", err)
|
||||
}
|
||||
|
||||
if len(results.Files) == 0 {
|
||||
return "", fmt.Errorf("postgresus_backups folder not found")
|
||||
}
|
||||
|
||||
return results.Files[0].Id, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package system_healthcheck
|
||||
|
||||
import (
|
||||
"postgresus-backend/internal/features/backups"
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
"postgresus-backend/internal/features/disk"
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package system_healthcheck
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"postgresus-backend/internal/features/backups"
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
"postgresus-backend/internal/features/disk"
|
||||
"postgresus-backend/internal/storage"
|
||||
)
|
||||
|
||||
@@ -5,14 +5,17 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"postgresus-backend/internal/config"
|
||||
"postgresus-backend/internal/features/backups"
|
||||
usecases_postgresql_backup "postgresus-backend/internal/features/backups/usecases/postgresql"
|
||||
"postgresus-backend/internal/features/backups/backups"
|
||||
usecases_postgresql_backup "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
|
||||
backups_config "postgresus-backend/internal/features/backups/config"
|
||||
"postgresus-backend/internal/features/databases"
|
||||
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
|
||||
"postgresus-backend/internal/features/intervals"
|
||||
"postgresus-backend/internal/features/restores/models"
|
||||
usecases_postgresql_restore "postgresus-backend/internal/features/restores/usecases/postgresql"
|
||||
"postgresus-backend/internal/features/storages"
|
||||
local_storage "postgresus-backend/internal/features/storages/models/local"
|
||||
"postgresus-backend/internal/util/period"
|
||||
"postgresus-backend/internal/util/tools"
|
||||
"strconv"
|
||||
"testing"
|
||||
@@ -99,7 +102,7 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
|
||||
backupID := uuid.New()
|
||||
pgVersionEnum := tools.GetPostgresqlVersionEnum(pgVersion)
|
||||
|
||||
backupDbConfig := &databases.Database{
|
||||
backupDb := &databases.Database{
|
||||
ID: uuid.New(),
|
||||
Type: databases.DatabaseTypePostgres,
|
||||
Name: "Test Database",
|
||||
@@ -111,10 +114,19 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
|
||||
Password: container.Password,
|
||||
Database: &container.Database,
|
||||
IsHttps: false,
|
||||
CpuCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
storageID := uuid.New()
|
||||
backupConfig := &backups_config.BackupConfig{
|
||||
DatabaseID: backupDb.ID,
|
||||
IsBackupsEnabled: true,
|
||||
StorePeriod: period.PeriodDay,
|
||||
BackupInterval: &intervals.Interval{Interval: intervals.IntervalDaily},
|
||||
StorageID: &storageID,
|
||||
CpuCount: 1,
|
||||
}
|
||||
|
||||
storage := &storages.Storage{
|
||||
UserID: uuid.New(),
|
||||
Type: storages.StorageTypeLocal,
|
||||
@@ -126,7 +138,8 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
|
||||
progressTracker := func(completedMBs float64) {}
|
||||
err = usecases_postgresql_backup.GetCreatePostgresqlBackupUsecase().Execute(
|
||||
backupID,
|
||||
backupDbConfig,
|
||||
backupConfig,
|
||||
backupDb,
|
||||
storage,
|
||||
progressTracker,
|
||||
)
|
||||
@@ -150,12 +163,12 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
|
||||
// Setup data for restore
|
||||
completedBackup := &backups.Backup{
|
||||
ID: backupID,
|
||||
DatabaseID: backupDbConfig.ID,
|
||||
DatabaseID: backupDb.ID,
|
||||
StorageID: storage.ID,
|
||||
Status: backups.BackupStatusCompleted,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Storage: storage,
|
||||
Database: backupDbConfig,
|
||||
Database: backupDb,
|
||||
}
|
||||
|
||||
restoreID := uuid.New()
|
||||
@@ -170,13 +183,12 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
|
||||
Password: container.Password,
|
||||
Database: &newDBName,
|
||||
IsHttps: false,
|
||||
CpuCount: 1,
|
||||
},
|
||||
}
|
||||
|
||||
// Restore the backup
|
||||
restoreBackupUC := usecases_postgresql_restore.GetRestorePostgresqlBackupUsecase()
|
||||
err = restoreBackupUC.Execute(restore, completedBackup, storage)
|
||||
err = restoreBackupUC.Execute(backupConfig, restore, completedBackup, storage)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify restored table exists
|
||||
|
||||
49
backend/internal/util/period/enums.go
Normal file
49
backend/internal/util/period/enums.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package period
|
||||
|
||||
import "time"
|
||||
|
||||
type Period string
|
||||
|
||||
const (
|
||||
PeriodDay Period = "DAY"
|
||||
PeriodWeek Period = "WEEK"
|
||||
PeriodMonth Period = "MONTH"
|
||||
Period3Month Period = "3_MONTH"
|
||||
Period6Month Period = "6_MONTH"
|
||||
PeriodYear Period = "YEAR"
|
||||
Period2Years Period = "2_YEARS"
|
||||
Period3Years Period = "3_YEARS"
|
||||
Period4Years Period = "4_YEARS"
|
||||
Period5Years Period = "5_YEARS"
|
||||
PeriodForever Period = "FOREVER"
|
||||
)
|
||||
|
||||
// ToDuration converts Period to time.Duration
|
||||
func (p Period) ToDuration() time.Duration {
|
||||
switch p {
|
||||
case PeriodDay:
|
||||
return 24 * time.Hour
|
||||
case PeriodWeek:
|
||||
return 7 * 24 * time.Hour
|
||||
case PeriodMonth:
|
||||
return 30 * 24 * time.Hour
|
||||
case Period3Month:
|
||||
return 90 * 24 * time.Hour
|
||||
case Period6Month:
|
||||
return 180 * 24 * time.Hour
|
||||
case PeriodYear:
|
||||
return 365 * 24 * time.Hour
|
||||
case Period2Years:
|
||||
return 2 * 365 * 24 * time.Hour
|
||||
case Period3Years:
|
||||
return 3 * 365 * 24 * time.Hour
|
||||
case Period4Years:
|
||||
return 4 * 365 * 24 * time.Hour
|
||||
case Period5Years:
|
||||
return 5 * 365 * 24 * time.Hour
|
||||
case PeriodForever:
|
||||
return 0
|
||||
default:
|
||||
panic("unknown period: " + string(p))
|
||||
}
|
||||
}
|
||||
94
backend/migrations/20250710122028_create_backup_config.sql
Normal file
94
backend/migrations/20250710122028_create_backup_config.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Create backup_configs table
|
||||
CREATE TABLE backup_configs (
|
||||
database_id UUID PRIMARY KEY,
|
||||
is_backups_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
store_period TEXT NOT NULL,
|
||||
backup_interval_id UUID NOT NULL,
|
||||
storage_id UUID,
|
||||
send_notifications_on TEXT NOT NULL,
|
||||
cpu_count INT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Add foreign key constraint
|
||||
ALTER TABLE backup_configs
|
||||
ADD CONSTRAINT fk_backup_config_database_id
|
||||
FOREIGN KEY (database_id)
|
||||
REFERENCES databases (id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE backup_configs
|
||||
ADD CONSTRAINT fk_backup_config_backup_interval_id
|
||||
FOREIGN KEY (backup_interval_id)
|
||||
REFERENCES intervals (id);
|
||||
|
||||
ALTER TABLE backup_configs
|
||||
ADD CONSTRAINT fk_backup_config_storage_id
|
||||
FOREIGN KEY (storage_id)
|
||||
REFERENCES storages (id);
|
||||
|
||||
-- Migrate data from databases table to backup_configs table
|
||||
INSERT INTO backup_configs (
|
||||
database_id,
|
||||
is_backups_enabled,
|
||||
store_period,
|
||||
backup_interval_id,
|
||||
storage_id,
|
||||
send_notifications_on,
|
||||
cpu_count
|
||||
)
|
||||
SELECT
|
||||
d.id,
|
||||
TRUE,
|
||||
d.store_period,
|
||||
d.backup_interval_id,
|
||||
d.storage_id,
|
||||
d.send_notifications_on,
|
||||
COALESCE(p.cpu_count, 1)
|
||||
FROM databases d
|
||||
LEFT JOIN postgresql_databases p ON d.id = p.database_id;
|
||||
|
||||
-- Remove backup-related columns from databases table
|
||||
ALTER TABLE databases DROP COLUMN store_period;
|
||||
ALTER TABLE databases DROP COLUMN backup_interval_id;
|
||||
ALTER TABLE databases DROP COLUMN storage_id;
|
||||
ALTER TABLE databases DROP COLUMN send_notifications_on;
|
||||
|
||||
-- Remove cpu_count column from postgresql_databases table
|
||||
ALTER TABLE postgresql_databases DROP COLUMN cpu_count;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Re-add backup-related columns to databases table
|
||||
ALTER TABLE databases ADD COLUMN store_period TEXT;
|
||||
ALTER TABLE databases ADD COLUMN backup_interval_id UUID;
|
||||
ALTER TABLE databases ADD COLUMN storage_id UUID;
|
||||
ALTER TABLE databases ADD COLUMN send_notifications_on TEXT;
|
||||
|
||||
-- Re-add cpu_count column to postgresql_databases table
|
||||
ALTER TABLE postgresql_databases ADD COLUMN cpu_count INT NOT NULL DEFAULT 1;
|
||||
|
||||
-- Migrate data back from backup_configs to databases and postgresql_databases tables
|
||||
UPDATE databases d
|
||||
SET
|
||||
store_period = bc.store_period,
|
||||
backup_interval_id = bc.backup_interval_id,
|
||||
storage_id = bc.storage_id,
|
||||
send_notifications_on = bc.send_notifications_on
|
||||
FROM backup_configs bc
|
||||
WHERE d.id = bc.database_id;
|
||||
|
||||
UPDATE postgresql_databases p
|
||||
SET cpu_count = bc.cpu_count
|
||||
FROM backup_configs bc
|
||||
WHERE p.database_id = bc.database_id;
|
||||
|
||||
-- Drop backup_configs table
|
||||
DROP TABLE backup_configs;
|
||||
|
||||
-- +goose StatementEnd
|
||||
35
frontend/src/entity/backups/api/backupConfigApi.ts
Normal file
35
frontend/src/entity/backups/api/backupConfigApi.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { getApplicationServer } from '../../../constants';
|
||||
import RequestOptions from '../../../shared/api/RequestOptions';
|
||||
import { apiHelper } from '../../../shared/api/apiHelper';
|
||||
import type { BackupConfig } from '../model/BackupConfig';
|
||||
|
||||
export const backupConfigApi = {
|
||||
async saveBackupConfig(config: BackupConfig) {
|
||||
const requestOptions: RequestOptions = new RequestOptions();
|
||||
requestOptions.setBody(JSON.stringify(config));
|
||||
return apiHelper.fetchPostJson<BackupConfig>(
|
||||
`${getApplicationServer()}/api/v1/backup-configs/save`,
|
||||
requestOptions,
|
||||
);
|
||||
},
|
||||
|
||||
async getBackupConfigByDbID(databaseId: string) {
|
||||
return apiHelper.fetchGetJson<BackupConfig>(
|
||||
`${getApplicationServer()}/api/v1/backup-configs/database/${databaseId}`,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
},
|
||||
|
||||
async isStorageUsing(storageId: string): Promise<boolean> {
|
||||
return await apiHelper
|
||||
.fetchGetJson<{
|
||||
isUsing: boolean;
|
||||
}>(
|
||||
`${getApplicationServer()}/api/v1/backup-configs/storage/${storageId}/is-using`,
|
||||
undefined,
|
||||
true,
|
||||
)
|
||||
.then((res) => res.isUsing);
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,6 @@
|
||||
export { backupsApi } from './api/backupsApi';
|
||||
export { backupConfigApi } from './api/backupConfigApi';
|
||||
export { BackupStatus } from './model/BackupStatus';
|
||||
export type { Backup } from './model/Backup';
|
||||
export type { BackupConfig } from './model/BackupConfig';
|
||||
export { BackupNotificationType } from './model/BackupNotificationType';
|
||||
|
||||
15
frontend/src/entity/backups/model/BackupConfig.ts
Normal file
15
frontend/src/entity/backups/model/BackupConfig.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Period } from '../../databases/model/Period';
|
||||
import type { Interval } from '../../intervals';
|
||||
import type { Storage } from '../../storages';
|
||||
import type { BackupNotificationType } from './BackupNotificationType';
|
||||
|
||||
export interface BackupConfig {
|
||||
databaseId: string;
|
||||
|
||||
isBackupsEnabled: boolean;
|
||||
storePeriod: Period;
|
||||
backupInterval?: Interval;
|
||||
storage?: Storage;
|
||||
sendNotificationsOn: BackupNotificationType[];
|
||||
cpuCount: number;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum BackupNotificationType {
|
||||
BackupFailed = 'BACKUP_FAILED',
|
||||
BackupSuccess = 'BACKUP_SUCCESS',
|
||||
}
|
||||
@@ -77,17 +77,4 @@ export const databaseApi = {
|
||||
)
|
||||
.then((res) => res.isUsing);
|
||||
},
|
||||
|
||||
async isStorageUsing(storageId: string): Promise<boolean> {
|
||||
const requestOptions: RequestOptions = new RequestOptions();
|
||||
return apiHelper
|
||||
.fetchGetJson<{
|
||||
isUsing: boolean;
|
||||
}>(
|
||||
`${getApplicationServer()}/api/v1/databases/storage/${storageId}/is-using`,
|
||||
requestOptions,
|
||||
true,
|
||||
)
|
||||
.then((res) => res.isUsing);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export enum BackupNotificationType {
|
||||
BACKUP_FAILED = 'BACKUP_FAILED',
|
||||
BACKUP_SUCCESS = 'BACKUP_SUCCESS',
|
||||
}
|
||||
@@ -1,26 +1,16 @@
|
||||
import type { Interval } from '../../intervals';
|
||||
import type { Notifier } from '../../notifiers';
|
||||
import type { BackupNotificationType } from './BackupNotificationType';
|
||||
import type { DatabaseType } from './DatabaseType';
|
||||
import type { HealthStatus } from './HealthStatus';
|
||||
import type { Period } from './Period';
|
||||
import type { PostgresqlDatabase } from './postgresql/PostgresqlDatabase';
|
||||
|
||||
export interface Database {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
type: DatabaseType;
|
||||
|
||||
backupInterval?: Interval;
|
||||
storePeriod: Period;
|
||||
|
||||
postgresql?: PostgresqlDatabase;
|
||||
|
||||
storage: Storage;
|
||||
|
||||
notifiers: Notifier[];
|
||||
sendNotificationsOn: BackupNotificationType[];
|
||||
|
||||
lastBackupTime?: Date;
|
||||
lastBackupErrorMessage?: string;
|
||||
|
||||
@@ -11,6 +11,4 @@ export interface PostgresqlDatabase {
|
||||
password: string;
|
||||
database?: string;
|
||||
isHttps: boolean;
|
||||
|
||||
cpuCount: number;
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export { BackupsComponent } from './ui/BackupsComponent';
|
||||
export { EditBackupConfigComponent } from './ui/EditBackupConfigComponent';
|
||||
export { ShowBackupConfigComponent } from './ui/ShowBackupConfigComponent';
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
InfoCircleOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Modal, Table, Tooltip } from 'antd';
|
||||
import { Button, Modal, Spin, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { type Backup, BackupStatus, backupsApi } from '../../../entity/backups';
|
||||
import { type Backup, BackupStatus, backupConfigApi, backupsApi } from '../../../entity/backups';
|
||||
import type { Database } from '../../../entity/databases';
|
||||
import { getUserTimeFormat } from '../../../shared/time';
|
||||
import { ConfirmationComponent } from '../../../shared/ui';
|
||||
@@ -22,9 +22,12 @@ interface Props {
|
||||
}
|
||||
|
||||
export const BackupsComponent = ({ database }: Props) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isBackupsLoading, setIsBackupsLoading] = useState(false);
|
||||
const [backups, setBackups] = useState<Backup[]>([]);
|
||||
|
||||
const [isBackupConfigLoading, setIsBackupConfigLoading] = useState(false);
|
||||
const [isShowBackupConfig, setIsShowBackupConfig] = useState(false);
|
||||
|
||||
const [isMakeBackupRequestLoading, setIsMakeBackupRequestLoading] = useState(false);
|
||||
|
||||
const [showingBackupError, setShowingBackupError] = useState<Backup | undefined>();
|
||||
@@ -86,11 +89,26 @@ export const BackupsComponent = ({ database }: Props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
loadBackups().then(() => setIsLoading(false));
|
||||
let isBackupsEnabled = false;
|
||||
|
||||
setIsBackupConfigLoading(true);
|
||||
backupConfigApi.getBackupConfigByDbID(database.id).then((backupConfig) => {
|
||||
setIsBackupConfigLoading(false);
|
||||
|
||||
if (backupConfig.isBackupsEnabled) {
|
||||
// load backups
|
||||
isBackupsEnabled = true;
|
||||
setIsShowBackupConfig(true);
|
||||
|
||||
setIsBackupsLoading(true);
|
||||
loadBackups().then(() => setIsBackupsLoading(false));
|
||||
}
|
||||
});
|
||||
|
||||
const interval = setInterval(() => {
|
||||
loadBackups();
|
||||
if (isBackupsEnabled) {
|
||||
loadBackups();
|
||||
}
|
||||
}, 1_000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
@@ -264,8 +282,20 @@ export const BackupsComponent = ({ database }: Props) => {
|
||||
},
|
||||
];
|
||||
|
||||
if (isBackupConfigLoading) {
|
||||
return (
|
||||
<div className="mb-5 flex items-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isShowBackupConfig) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-5 w-full rounded bg-white p-5 shadow">
|
||||
<h2 className="text-xl font-bold">Backups</h2>
|
||||
|
||||
<div className="mt-5" />
|
||||
@@ -288,7 +318,7 @@ export const BackupsComponent = ({ database }: Props) => {
|
||||
columns={columns}
|
||||
dataSource={backups}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
loading={isBackupsLoading}
|
||||
size="small"
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
483
frontend/src/features/backups/ui/EditBackupConfigComponent.tsx
Normal file
483
frontend/src/features/backups/ui/EditBackupConfigComponent.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Spin,
|
||||
Switch,
|
||||
TimePicker,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { type BackupConfig, backupConfigApi } from '../../../entity/backups';
|
||||
import { BackupNotificationType } from '../../../entity/backups/model/BackupNotificationType';
|
||||
import type { Database } from '../../../entity/databases';
|
||||
import { Period } from '../../../entity/databases/model/Period';
|
||||
import { type Interval, IntervalType } from '../../../entity/intervals';
|
||||
import { type Storage, getStorageLogoFromType, storageApi } from '../../../entity/storages';
|
||||
import {
|
||||
getLocalDayOfMonth,
|
||||
getLocalWeekday,
|
||||
getUserTimeFormat,
|
||||
getUtcDayOfMonth,
|
||||
getUtcWeekday,
|
||||
} from '../../../shared/time/utils';
|
||||
import { ConfirmationComponent } from '../../../shared/ui';
|
||||
import { EditStorageComponent } from '../../storages/ui/edit/EditStorageComponent';
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
|
||||
isShowBackButton: boolean;
|
||||
onBack: () => void;
|
||||
|
||||
isShowCancelButton?: boolean;
|
||||
onCancel: () => void;
|
||||
|
||||
saveButtonText?: string;
|
||||
isSaveToApi: boolean;
|
||||
onSaved: (backupConfig: BackupConfig) => void;
|
||||
}
|
||||
|
||||
const weekdayOptions = [
|
||||
{ value: 1, label: 'Mon' },
|
||||
{ value: 2, label: 'Tue' },
|
||||
{ value: 3, label: 'Wed' },
|
||||
{ value: 4, label: 'Thu' },
|
||||
{ value: 5, label: 'Fri' },
|
||||
{ value: 6, label: 'Sat' },
|
||||
{ value: 7, label: 'Sun' },
|
||||
];
|
||||
|
||||
export const EditBackupConfigComponent = ({
|
||||
database,
|
||||
|
||||
isShowBackButton,
|
||||
onBack,
|
||||
|
||||
isShowCancelButton,
|
||||
onCancel,
|
||||
saveButtonText,
|
||||
isSaveToApi,
|
||||
onSaved,
|
||||
}: Props) => {
|
||||
const [backupConfig, setBackupConfig] = useState<BackupConfig>();
|
||||
const [isUnsaved, setIsUnsaved] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [storages, setStorages] = useState<Storage[]>([]);
|
||||
const [isStoragesLoading, setIsStoragesLoading] = useState(false);
|
||||
const [isShowCreateStorage, setShowCreateStorage] = useState(false);
|
||||
|
||||
const [isShowWarn, setIsShowWarn] = useState(false);
|
||||
|
||||
const timeFormat = useMemo(() => {
|
||||
const is12 = getUserTimeFormat();
|
||||
return { use12Hours: is12, format: is12 ? 'h:mm A' : 'HH:mm' };
|
||||
}, []);
|
||||
|
||||
const updateBackupConfig = (patch: Partial<BackupConfig>) => {
|
||||
setBackupConfig((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
setIsUnsaved(true);
|
||||
};
|
||||
|
||||
const saveInterval = (patch: Partial<Interval>) => {
|
||||
setBackupConfig((prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
const updatedBackupInterval = { ...(prev.backupInterval ?? {}), ...patch };
|
||||
|
||||
if (!updatedBackupInterval.id && prev.backupInterval?.id) {
|
||||
updatedBackupInterval.id = prev.backupInterval.id;
|
||||
}
|
||||
|
||||
return { ...prev, backupInterval: updatedBackupInterval as Interval };
|
||||
});
|
||||
|
||||
setIsUnsaved(true);
|
||||
};
|
||||
|
||||
const saveBackupConfig = async () => {
|
||||
if (!backupConfig) return;
|
||||
|
||||
if (isSaveToApi) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await backupConfigApi.saveBackupConfig(backupConfig);
|
||||
setIsUnsaved(false);
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
}
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
onSaved(backupConfig);
|
||||
};
|
||||
|
||||
const loadStorages = async () => {
|
||||
setIsStoragesLoading(true);
|
||||
|
||||
try {
|
||||
const storages = await storageApi.getStorages();
|
||||
setStorages(storages);
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
}
|
||||
|
||||
setIsStoragesLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (database.id) {
|
||||
backupConfigApi.getBackupConfigByDbID(database.id).then((res) => {
|
||||
setBackupConfig(res);
|
||||
setIsUnsaved(false);
|
||||
setIsSaving(false);
|
||||
});
|
||||
} else {
|
||||
setBackupConfig({
|
||||
databaseId: database.id,
|
||||
isBackupsEnabled: true,
|
||||
backupInterval: {
|
||||
id: undefined as unknown as string,
|
||||
interval: IntervalType.DAILY,
|
||||
timeOfDay: '00:00',
|
||||
},
|
||||
storage: undefined,
|
||||
cpuCount: 1,
|
||||
storePeriod: Period.WEEK,
|
||||
sendNotificationsOn: [],
|
||||
});
|
||||
}
|
||||
loadStorages();
|
||||
}, [database]);
|
||||
|
||||
if (!backupConfig) return <div />;
|
||||
|
||||
if (isStoragesLoading) {
|
||||
return (
|
||||
<div className="mb-5 flex items-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { backupInterval } = backupConfig;
|
||||
|
||||
// UTC → local conversions for display
|
||||
const localTime: Dayjs | undefined = backupInterval?.timeOfDay
|
||||
? dayjs.utc(backupInterval.timeOfDay, 'HH:mm').local()
|
||||
: undefined;
|
||||
|
||||
const displayedWeekday: number | undefined =
|
||||
backupInterval?.interval === IntervalType.WEEKLY &&
|
||||
backupInterval.weekday &&
|
||||
backupInterval.timeOfDay
|
||||
? getLocalWeekday(backupInterval.weekday, backupInterval.timeOfDay)
|
||||
: backupInterval?.weekday;
|
||||
|
||||
const displayedDayOfMonth: number | undefined =
|
||||
backupInterval?.interval === IntervalType.MONTHLY &&
|
||||
backupInterval.dayOfMonth &&
|
||||
backupInterval.timeOfDay
|
||||
? getLocalDayOfMonth(backupInterval.dayOfMonth, backupInterval.timeOfDay)
|
||||
: backupInterval?.dayOfMonth;
|
||||
|
||||
// mandatory-field check
|
||||
const isAllFieldsFilled =
|
||||
!backupConfig.isBackupsEnabled ||
|
||||
(Boolean(backupConfig.storePeriod) &&
|
||||
Boolean(backupConfig.storage?.id) &&
|
||||
Boolean(backupConfig.cpuCount) &&
|
||||
Boolean(backupInterval?.interval) &&
|
||||
(!backupInterval ||
|
||||
((backupInterval.interval !== IntervalType.WEEKLY || displayedWeekday) &&
|
||||
(backupInterval.interval !== IntervalType.MONTHLY || displayedDayOfMonth))));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backups enabled</div>
|
||||
<Switch
|
||||
checked={backupConfig.isBackupsEnabled}
|
||||
onChange={(checked) => updateBackupConfig({ isBackupsEnabled: checked })}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{backupConfig.isBackupsEnabled && (
|
||||
<>
|
||||
<div className="mt-4 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup interval</div>
|
||||
<Select
|
||||
value={backupInterval?.interval}
|
||||
onChange={(v) => saveInterval({ interval: v })}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
options={[
|
||||
{ label: 'Hourly', value: IntervalType.HOURLY },
|
||||
{ label: 'Daily', value: IntervalType.DAILY },
|
||||
{ label: 'Weekly', value: IntervalType.WEEKLY },
|
||||
{ label: 'Monthly', value: IntervalType.MONTHLY },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{backupInterval?.interval === IntervalType.WEEKLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup weekday</div>
|
||||
<Select
|
||||
value={displayedWeekday}
|
||||
onChange={(localWeekday) => {
|
||||
if (!localWeekday) return;
|
||||
const ref = localTime ?? dayjs();
|
||||
saveInterval({ weekday: getUtcWeekday(localWeekday, ref) });
|
||||
}}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
options={weekdayOptions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupInterval?.interval === IntervalType.MONTHLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup day of month</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={31}
|
||||
value={displayedDayOfMonth}
|
||||
onChange={(localDom) => {
|
||||
if (!localDom) return;
|
||||
const ref = localTime ?? dayjs();
|
||||
saveInterval({ dayOfMonth: getUtcDayOfMonth(localDom, ref) });
|
||||
}}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupInterval?.interval !== IntervalType.HOURLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup time of day</div>
|
||||
<TimePicker
|
||||
value={localTime}
|
||||
format={timeFormat.format}
|
||||
use12Hours={timeFormat.use12Hours}
|
||||
allowClear={false}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
onChange={(t) => {
|
||||
if (!t) return;
|
||||
const patch: Partial<Interval> = { timeOfDay: t.utc().format('HH:mm') };
|
||||
|
||||
if (backupInterval?.interval === IntervalType.WEEKLY && displayedWeekday) {
|
||||
patch.weekday = getUtcWeekday(displayedWeekday, t);
|
||||
}
|
||||
if (backupInterval?.interval === IntervalType.MONTHLY && displayedDayOfMonth) {
|
||||
patch.dayOfMonth = getUtcDayOfMonth(displayedDayOfMonth, t);
|
||||
}
|
||||
|
||||
saveInterval(patch);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">CPU count</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={16}
|
||||
value={backupConfig.cpuCount}
|
||||
onChange={(value) => updateBackupConfig({ cpuCount: value || 1 })}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="Number of CPU cores to use for backup processing. Higher values may speed up backups but use more resources."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Store period</div>
|
||||
<Select
|
||||
value={backupConfig.storePeriod}
|
||||
onChange={(v) => updateBackupConfig({ storePeriod: v })}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
options={[
|
||||
{ label: '1 day', value: Period.DAY },
|
||||
{ label: '1 week', value: Period.WEEK },
|
||||
{ label: '1 month', value: Period.MONTH },
|
||||
{ label: '3 months', value: Period.THREE_MONTH },
|
||||
{ label: '6 months', value: Period.SIX_MONTH },
|
||||
{ label: '1 year', value: Period.YEAR },
|
||||
{ label: '2 years', value: Period.TWO_YEARS },
|
||||
{ label: '3 years', value: Period.THREE_YEARS },
|
||||
{ label: '4 years', value: Period.FOUR_YEARS },
|
||||
{ label: '5 years', value: Period.FIVE_YEARS },
|
||||
{ label: 'Forever', value: Period.FOREVER },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="How long to keep the backups? Make sure you have enough storage space."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Storage</div>
|
||||
<Select
|
||||
value={backupConfig.storage?.id}
|
||||
onChange={(storageId) => {
|
||||
if (storageId.includes('create-new-storage')) {
|
||||
setShowCreateStorage(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedStorage = storages.find((s) => s.id === storageId);
|
||||
updateBackupConfig({ storage: selectedStorage });
|
||||
|
||||
if (backupConfig.storage?.id) {
|
||||
setIsShowWarn(true);
|
||||
}
|
||||
}}
|
||||
size="small"
|
||||
className="mr-2 max-w-[200px] grow"
|
||||
options={[
|
||||
...storages.map((s) => ({ label: s.name, value: s.id })),
|
||||
{ label: 'Create new storage', value: 'create-new-storage' },
|
||||
]}
|
||||
placeholder="Select storage"
|
||||
/>
|
||||
|
||||
{backupConfig.storage?.type && (
|
||||
<img
|
||||
src={getStorageLogoFromType(backupConfig.storage.type)}
|
||||
alt="storageIcon"
|
||||
className="ml-1 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 mb-1 flex w-full items-start">
|
||||
<div className="mt-1 min-w-[150px]">Notifications</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Checkbox
|
||||
checked={backupConfig.sendNotificationsOn.includes(
|
||||
BackupNotificationType.BackupSuccess,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const notifications = [...backupConfig.sendNotificationsOn];
|
||||
const index = notifications.indexOf(BackupNotificationType.BackupSuccess);
|
||||
if (e.target.checked && index === -1) {
|
||||
notifications.push(BackupNotificationType.BackupSuccess);
|
||||
} else if (!e.target.checked && index > -1) {
|
||||
notifications.splice(index, 1);
|
||||
}
|
||||
updateBackupConfig({ sendNotificationsOn: notifications });
|
||||
}}
|
||||
>
|
||||
Backup success
|
||||
</Checkbox>
|
||||
|
||||
<Checkbox
|
||||
checked={backupConfig.sendNotificationsOn.includes(
|
||||
BackupNotificationType.BackupFailed,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const notifications = [...backupConfig.sendNotificationsOn];
|
||||
const index = notifications.indexOf(BackupNotificationType.BackupFailed);
|
||||
if (e.target.checked && index === -1) {
|
||||
notifications.push(BackupNotificationType.BackupFailed);
|
||||
} else if (!e.target.checked && index > -1) {
|
||||
notifications.splice(index, 1);
|
||||
}
|
||||
updateBackupConfig({ sendNotificationsOn: notifications });
|
||||
}}
|
||||
>
|
||||
Backup failed
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex">
|
||||
{isShowBackButton && (
|
||||
<Button className="mr-1" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isShowCancelButton && (
|
||||
<Button danger ghost className="mr-1" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className={`${isShowCancelButton ? 'ml-1' : 'ml-auto'} mr-5`}
|
||||
onClick={saveBackupConfig}
|
||||
loading={isSaving}
|
||||
disabled={!isUnsaved || !isAllFieldsFilled}
|
||||
>
|
||||
{saveButtonText || 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isShowCreateStorage && (
|
||||
<Modal
|
||||
title="Add storage"
|
||||
footer={<div />}
|
||||
open={isShowCreateStorage}
|
||||
onCancel={() => setShowCreateStorage(false)}
|
||||
>
|
||||
<div className="my-3 max-w-[275px] text-gray-500">
|
||||
Storage - is a place where backups will be stored (local disk, S3, Google Drive, etc.)
|
||||
</div>
|
||||
|
||||
<EditStorageComponent
|
||||
isShowName
|
||||
isShowClose={false}
|
||||
onClose={() => setShowCreateStorage(false)}
|
||||
onChanged={() => {
|
||||
loadStorages();
|
||||
setShowCreateStorage(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{isShowWarn && (
|
||||
<ConfirmationComponent
|
||||
onConfirm={() => {
|
||||
setIsShowWarn(false);
|
||||
}}
|
||||
onDecline={() => {
|
||||
setIsShowWarn(false);
|
||||
}}
|
||||
description="If you change the storage, all backups in this storage will be deleted."
|
||||
actionButtonColor="red"
|
||||
actionText="I understand"
|
||||
cancelText="Cancel"
|
||||
hideCancelButton
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
172
frontend/src/features/backups/ui/ShowBackupConfigComponent.tsx
Normal file
172
frontend/src/features/backups/ui/ShowBackupConfigComponent.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { type BackupConfig, backupConfigApi } from '../../../entity/backups';
|
||||
import { BackupNotificationType } from '../../../entity/backups/model/BackupNotificationType';
|
||||
import type { Database } from '../../../entity/databases';
|
||||
import { Period } from '../../../entity/databases/model/Period';
|
||||
import { IntervalType } from '../../../entity/intervals';
|
||||
import { getStorageLogoFromType } from '../../../entity/storages/models/getStorageLogoFromType';
|
||||
import { getLocalDayOfMonth, getLocalWeekday, getUserTimeFormat } from '../../../shared/time/utils';
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
}
|
||||
|
||||
const weekdayLabels = {
|
||||
1: 'Mon',
|
||||
2: 'Tue',
|
||||
3: 'Wed',
|
||||
4: 'Thu',
|
||||
5: 'Fri',
|
||||
6: 'Sat',
|
||||
7: 'Sun',
|
||||
};
|
||||
|
||||
const intervalLabels = {
|
||||
[IntervalType.HOURLY]: 'Hourly',
|
||||
[IntervalType.DAILY]: 'Daily',
|
||||
[IntervalType.WEEKLY]: 'Weekly',
|
||||
[IntervalType.MONTHLY]: 'Monthly',
|
||||
};
|
||||
|
||||
const periodLabels = {
|
||||
[Period.DAY]: '1 day',
|
||||
[Period.WEEK]: '1 week',
|
||||
[Period.MONTH]: '1 month',
|
||||
[Period.THREE_MONTH]: '3 months',
|
||||
[Period.SIX_MONTH]: '6 months',
|
||||
[Period.YEAR]: '1 year',
|
||||
[Period.TWO_YEARS]: '2 years',
|
||||
[Period.THREE_YEARS]: '3 years',
|
||||
[Period.FOUR_YEARS]: '4 years',
|
||||
[Period.FIVE_YEARS]: '5 years',
|
||||
[Period.FOREVER]: 'Forever',
|
||||
};
|
||||
|
||||
const notificationLabels = {
|
||||
[BackupNotificationType.BackupFailed]: 'Backup failed',
|
||||
[BackupNotificationType.BackupSuccess]: 'Backup success',
|
||||
};
|
||||
|
||||
export const ShowBackupConfigComponent = ({ database }: Props) => {
|
||||
const [backupConfig, setBackupConfig] = useState<BackupConfig>();
|
||||
|
||||
// Detect user's preferred time format (12-hour vs 24-hour)
|
||||
const timeFormat = useMemo(() => {
|
||||
const is12Hour = getUserTimeFormat();
|
||||
return {
|
||||
use12Hours: is12Hour,
|
||||
format: is12Hour ? 'h:mm A' : 'HH:mm',
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (database.id) {
|
||||
backupConfigApi.getBackupConfigByDbID(database.id).then((res) => {
|
||||
setBackupConfig(res);
|
||||
});
|
||||
}
|
||||
}, [database]);
|
||||
|
||||
if (!backupConfig) return <div />;
|
||||
|
||||
const { backupInterval } = backupConfig;
|
||||
|
||||
const localTime = backupInterval?.timeOfDay
|
||||
? dayjs.utc(backupInterval.timeOfDay, 'HH:mm').local()
|
||||
: undefined;
|
||||
|
||||
const formattedTime = localTime ? localTime.format(timeFormat.format) : '';
|
||||
|
||||
// Convert UTC weekday/day-of-month to local equivalents for display
|
||||
const displayedWeekday: number | undefined =
|
||||
backupInterval?.interval === IntervalType.WEEKLY &&
|
||||
backupInterval.weekday &&
|
||||
backupInterval.timeOfDay
|
||||
? getLocalWeekday(backupInterval.weekday, backupInterval.timeOfDay)
|
||||
: backupInterval?.weekday;
|
||||
|
||||
const displayedDayOfMonth: number | undefined =
|
||||
backupInterval?.interval === IntervalType.MONTHLY &&
|
||||
backupInterval.dayOfMonth &&
|
||||
backupInterval.timeOfDay
|
||||
? getLocalDayOfMonth(backupInterval.dayOfMonth, backupInterval.timeOfDay)
|
||||
: backupInterval?.dayOfMonth;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backups enabled</div>
|
||||
<div>{backupConfig.isBackupsEnabled ? 'Yes' : 'No'}</div>
|
||||
</div>
|
||||
|
||||
{backupConfig.isBackupsEnabled ? (
|
||||
<>
|
||||
<div className="mt-4 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup interval</div>
|
||||
<div>{backupInterval?.interval ? intervalLabels[backupInterval.interval] : ''}</div>
|
||||
</div>
|
||||
|
||||
{backupInterval?.interval === IntervalType.WEEKLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup weekday</div>
|
||||
<div>
|
||||
{displayedWeekday
|
||||
? weekdayLabels[displayedWeekday as keyof typeof weekdayLabels]
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupInterval?.interval === IntervalType.MONTHLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup day of month</div>
|
||||
<div>{displayedDayOfMonth || ''}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupInterval?.interval !== IntervalType.HOURLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup time of day</div>
|
||||
<div>{formattedTime}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Store period</div>
|
||||
<div>{backupConfig.storePeriod ? periodLabels[backupConfig.storePeriod] : ''}</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Storage</div>
|
||||
<div className="flex items-center">
|
||||
<div>{backupConfig.storage?.name || ''}</div>
|
||||
{backupConfig.storage?.type && (
|
||||
<img
|
||||
src={getStorageLogoFromType(backupConfig.storage.type)}
|
||||
alt="storageIcon"
|
||||
className="ml-1 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Notifications</div>
|
||||
<div>
|
||||
{backupConfig.sendNotificationsOn.length > 0
|
||||
? backupConfig.sendNotificationsOn
|
||||
.map((type) => notificationLabels[type])
|
||||
.join(', ')
|
||||
: 'None'}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { backupsApi } from '../../../entity/backups';
|
||||
import { type BackupConfig, backupConfigApi, backupsApi } from '../../../entity/backups';
|
||||
import {
|
||||
type Database,
|
||||
DatabaseType,
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
type PostgresqlDatabase,
|
||||
databaseApi,
|
||||
} from '../../../entity/databases';
|
||||
import { EditBackupConfigComponent } from '../../backups';
|
||||
import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent';
|
||||
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
|
||||
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
|
||||
import { EditDatabaseStorageComponent } from './edit/EditDatabaseStorageComponent';
|
||||
|
||||
interface Props {
|
||||
onCreated: () => void;
|
||||
@@ -21,6 +21,7 @@ interface Props {
|
||||
|
||||
export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [backupConfig, setBackupConfig] = useState<BackupConfig | undefined>();
|
||||
const [database, setDatabase] = useState<Database>({
|
||||
id: undefined as unknown as string,
|
||||
name: '',
|
||||
@@ -38,18 +39,23 @@ export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => {
|
||||
sendNotificationsOn: [],
|
||||
} as Database);
|
||||
|
||||
const [step, setStep] = useState<'base-info' | 'db-settings' | 'storages' | 'notifiers'>(
|
||||
const [step, setStep] = useState<'base-info' | 'db-settings' | 'backup-config' | 'notifiers'>(
|
||||
'base-info',
|
||||
);
|
||||
|
||||
const createDatabase = async (database: Database) => {
|
||||
const createDatabase = async (database: Database, backupConfig: BackupConfig) => {
|
||||
setIsCreating(true);
|
||||
|
||||
try {
|
||||
const createdDatabase = await databaseApi.createDatabase(database);
|
||||
setDatabase({ ...createdDatabase });
|
||||
|
||||
await backupsApi.makeBackup(createdDatabase.id);
|
||||
backupConfig.databaseId = createdDatabase.id;
|
||||
await backupConfigApi.saveBackupConfig(backupConfig);
|
||||
if (backupConfig.isBackupsEnabled) {
|
||||
await backupsApi.makeBackup(createdDatabase.id);
|
||||
}
|
||||
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -89,25 +95,24 @@ export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => {
|
||||
isSaveToApi={false}
|
||||
onSaved={(database) => {
|
||||
setDatabase({ ...database });
|
||||
setStep('storages');
|
||||
setStep('backup-config');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'storages') {
|
||||
if (step === 'backup-config') {
|
||||
return (
|
||||
<EditDatabaseStorageComponent
|
||||
<EditBackupConfigComponent
|
||||
database={database}
|
||||
isShowCancelButton={false}
|
||||
onCancel={() => onClose()}
|
||||
isShowBackButton
|
||||
onBack={() => setStep('db-settings')}
|
||||
isShowSaveOnlyForUnsaved={false}
|
||||
saveButtonText="Continue"
|
||||
isSaveToApi={false}
|
||||
onSaved={(database) => {
|
||||
setDatabase({ ...database });
|
||||
onSaved={(backupConfig) => {
|
||||
setBackupConfig(backupConfig);
|
||||
setStep('notifiers');
|
||||
}}
|
||||
/>
|
||||
@@ -121,7 +126,7 @@ export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => {
|
||||
isShowCancelButton={false}
|
||||
onCancel={() => onClose()}
|
||||
isShowBackButton
|
||||
onBack={() => setStep('storages')}
|
||||
onBack={() => setStep('backup-config')}
|
||||
isShowSaveOnlyForUnsaved={false}
|
||||
saveButtonText="Complete"
|
||||
isSaveToApi={false}
|
||||
@@ -129,7 +134,7 @@ export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => {
|
||||
if (isCreating) return;
|
||||
|
||||
setDatabase({ ...database });
|
||||
createDatabase(database);
|
||||
createDatabase(database, backupConfig!);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import dayjs from 'dayjs';
|
||||
|
||||
import { type Database, DatabaseType } from '../../../entity/databases';
|
||||
import { HealthStatus } from '../../../entity/databases/model/HealthStatus';
|
||||
import { getStorageLogoFromType } from '../../../entity/storages';
|
||||
import { getUserShortTimeFormat } from '../../../shared/time/getUserTimeFormat';
|
||||
|
||||
interface Props {
|
||||
@@ -52,16 +51,6 @@ export const DatabaseCardComponent = ({
|
||||
<img src={databaseIcon} alt="databaseIcon" className="ml-1 h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div className="mb flex items-center">
|
||||
<div className="text-sm text-gray-500">Store to: {database.storage?.name} </div>
|
||||
|
||||
<img
|
||||
src={getStorageLogoFromType(database.storage?.type)}
|
||||
alt="databaseIcon"
|
||||
className="ml-1 h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{database.lastBackupTime && (
|
||||
<div className="mt-3 mb-1 text-xs text-gray-500">
|
||||
<span className="font-bold">Last backup</span>
|
||||
|
||||
@@ -6,20 +6,20 @@ import { useEffect } from 'react';
|
||||
import { type Database, databaseApi } from '../../../entity/databases';
|
||||
import { ToastHelper } from '../../../shared/toast';
|
||||
import { ConfirmationComponent } from '../../../shared/ui';
|
||||
import { BackupsComponent } from '../../backups';
|
||||
import {
|
||||
BackupsComponent,
|
||||
EditBackupConfigComponent,
|
||||
ShowBackupConfigComponent,
|
||||
} from '../../backups';
|
||||
import {
|
||||
EditHealthcheckConfigComponent,
|
||||
HealthckeckAttemptsComponent,
|
||||
ShowHealthcheckConfigComponent,
|
||||
} from '../../healthcheck';
|
||||
import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent';
|
||||
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
|
||||
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
|
||||
import { EditDatabaseStorageComponent } from './edit/EditDatabaseStorageComponent';
|
||||
import { ShowDatabaseBaseInfoComponent } from './show/ShowDatabaseBaseInfoComponent';
|
||||
import { ShowDatabaseNotifiersComponent } from './show/ShowDatabaseNotifiersComponent';
|
||||
import { ShowDatabaseSpecificDataComponent } from './show/ShowDatabaseSpecificDataComponent';
|
||||
import { ShowDatabaseStorageComponent } from './show/ShowDatabaseStorageComponent';
|
||||
|
||||
interface Props {
|
||||
contentHeight: number;
|
||||
@@ -37,10 +37,9 @@ export const DatabaseComponent = ({
|
||||
const [database, setDatabase] = useState<Database | undefined>();
|
||||
|
||||
const [isEditName, setIsEditName] = useState(false);
|
||||
const [isEditBaseSettings, setIsEditBaseSettings] = useState(false);
|
||||
const [isEditDatabaseSpecificDataSettings, setIsEditDatabaseSpecificDataSettings] =
|
||||
useState(false);
|
||||
const [isEditStorageSettings, setIsEditStorageSettings] = useState(false);
|
||||
const [isEditBackupConfig, setIsEditBackupConfig] = useState(false);
|
||||
const [isEditNotifiersSettings, setIsEditNotifiersSettings] = useState(false);
|
||||
const [isEditHealthcheckSettings, setIsEditHealthcheckSettings] = useState(false);
|
||||
|
||||
@@ -95,14 +94,11 @@ export const DatabaseComponent = ({
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (
|
||||
type: 'name' | 'settings' | 'database' | 'storage' | 'notifiers' | 'healthcheck',
|
||||
) => {
|
||||
const startEdit = (type: 'name' | 'database' | 'backup-config' | 'notifiers' | 'healthcheck') => {
|
||||
setEditDatabase(JSON.parse(JSON.stringify(database)));
|
||||
setIsEditName(type === 'name');
|
||||
setIsEditBaseSettings(type === 'settings');
|
||||
setIsEditDatabaseSpecificDataSettings(type === 'database');
|
||||
setIsEditStorageSettings(type === 'storage');
|
||||
setIsEditBackupConfig(type === 'backup-config');
|
||||
setIsEditNotifiersSettings(type === 'notifiers');
|
||||
setIsEditHealthcheckSettings(type === 'healthcheck');
|
||||
setIsNameUnsaved(false);
|
||||
@@ -222,41 +218,6 @@ export const DatabaseComponent = ({
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[350px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Backup settings</div>
|
||||
|
||||
{!isEditBaseSettings ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('settings')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditBaseSettings ? (
|
||||
<EditDatabaseBaseInfoComponent
|
||||
isShowName={false}
|
||||
database={database}
|
||||
isShowCancelButton
|
||||
onCancel={() => {
|
||||
setIsEditBaseSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
isSaveToApi={true}
|
||||
onSaved={onDatabaseChanged}
|
||||
/>
|
||||
) : (
|
||||
<ShowDatabaseBaseInfoComponent database={database} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[350px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Database settings</div>
|
||||
@@ -292,17 +253,15 @@ export const DatabaseComponent = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[350px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Storage settings</div>
|
||||
<div>Backup config</div>
|
||||
|
||||
{!isEditStorageSettings ? (
|
||||
{!isEditBackupConfig ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('storage')}
|
||||
onClick={() => startEdit('backup-config')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
@@ -313,26 +272,58 @@ export const DatabaseComponent = ({
|
||||
|
||||
<div>
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditStorageSettings ? (
|
||||
<EditDatabaseStorageComponent
|
||||
{isEditBackupConfig ? (
|
||||
<EditBackupConfigComponent
|
||||
database={database}
|
||||
isShowCancelButton
|
||||
isShowBackButton={false}
|
||||
isShowSaveOnlyForUnsaved={true}
|
||||
onBack={() => {}}
|
||||
onCancel={() => {
|
||||
setIsEditStorageSettings(false);
|
||||
setIsEditBackupConfig(false);
|
||||
loadSettings();
|
||||
}}
|
||||
isSaveToApi={true}
|
||||
onSaved={onDatabaseChanged}
|
||||
onSaved={() => onDatabaseChanged(database)}
|
||||
isShowBackButton={false}
|
||||
onBack={() => {}}
|
||||
/>
|
||||
) : (
|
||||
<ShowDatabaseStorageComponent database={database} />
|
||||
<ShowBackupConfigComponent database={database} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[350px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Healthcheck settings</div>
|
||||
|
||||
{!isEditHealthcheckSettings ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('healthcheck')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditHealthcheckSettings ? (
|
||||
<EditHealthcheckConfigComponent
|
||||
databaseId={database.id}
|
||||
onClose={() => {
|
||||
setIsEditHealthcheckSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ShowHealthcheckConfigComponent databaseId={database.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[350px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
@@ -373,39 +364,6 @@ export const DatabaseComponent = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-10">
|
||||
<div className="w-[350px]">
|
||||
<div className="mt-5 flex items-center font-bold">
|
||||
<div>Healthcheck settings</div>
|
||||
|
||||
{!isEditHealthcheckSettings ? (
|
||||
<div
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => startEdit('healthcheck')}
|
||||
>
|
||||
<img src="/icons/pen-gray.svg" />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-sm">
|
||||
{isEditHealthcheckSettings ? (
|
||||
<EditHealthcheckConfigComponent
|
||||
databaseId={database.id}
|
||||
onClose={() => {
|
||||
setIsEditHealthcheckSettings(false);
|
||||
loadSettings();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ShowHealthcheckConfigComponent databaseId={database.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditDatabaseSpecificDataSettings && (
|
||||
<div className="mt-10">
|
||||
<Button
|
||||
@@ -445,13 +403,8 @@ export const DatabaseComponent = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 w-full rounded bg-white p-5 shadow">
|
||||
{database && <HealthckeckAttemptsComponent database={database} />}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 w-full rounded bg-white p-5 shadow">
|
||||
{database && <BackupsComponent database={database} />}
|
||||
</div>
|
||||
{database && <HealthckeckAttemptsComponent database={database} />}
|
||||
{database && <BackupsComponent database={database} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, InputNumber, Select, TimePicker, Tooltip } from 'antd';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Input } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { type Database, databaseApi } from '../../../../entity/databases';
|
||||
import { Period } from '../../../../entity/databases/model/Period';
|
||||
import { type Interval, IntervalType } from '../../../../entity/intervals';
|
||||
import {
|
||||
getLocalDayOfMonth,
|
||||
getLocalWeekday,
|
||||
getUserTimeFormat,
|
||||
getUtcDayOfMonth,
|
||||
getUtcWeekday,
|
||||
} from '../../../../shared/time/utils';
|
||||
|
||||
const weekdayOptions = [
|
||||
{ value: 1, label: 'Mon' },
|
||||
{ value: 2, label: 'Tue' },
|
||||
{ value: 3, label: 'Wed' },
|
||||
{ value: 4, label: 'Thu' },
|
||||
{ value: 5, label: 'Fri' },
|
||||
{ value: 6, label: 'Sat' },
|
||||
{ value: 7, label: 'Sun' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
@@ -49,32 +28,11 @@ export const EditDatabaseBaseInfoComponent = ({
|
||||
const [isUnsaved, setIsUnsaved] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const timeFormat = useMemo(() => {
|
||||
const is12 = getUserTimeFormat();
|
||||
return { use12Hours: is12, format: is12 ? 'h:mm A' : 'HH:mm' };
|
||||
}, []);
|
||||
|
||||
const updateDatabase = (patch: Partial<Database>) => {
|
||||
setEditingDatabase((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
setIsUnsaved(true);
|
||||
};
|
||||
|
||||
const saveInterval = (patch: Partial<Interval>) => {
|
||||
setEditingDatabase((prev) => {
|
||||
if (!prev) return prev;
|
||||
|
||||
const updatedBackupInterval = { ...(prev.backupInterval ?? {}), ...patch };
|
||||
|
||||
if (!updatedBackupInterval.id && prev.backupInterval?.id) {
|
||||
updatedBackupInterval.id = prev.backupInterval.id;
|
||||
}
|
||||
|
||||
return { ...prev, backupInterval: updatedBackupInterval as Interval };
|
||||
});
|
||||
|
||||
setIsUnsaved(true);
|
||||
};
|
||||
|
||||
const saveDatabase = async () => {
|
||||
if (!editingDatabase) return;
|
||||
if (isSaveToApi) {
|
||||
@@ -97,35 +55,9 @@ export const EditDatabaseBaseInfoComponent = ({
|
||||
}, [database]);
|
||||
|
||||
if (!editingDatabase) return null;
|
||||
const { backupInterval } = editingDatabase;
|
||||
|
||||
// UTC → local conversions for display
|
||||
const localTime: Dayjs | undefined = backupInterval?.timeOfDay
|
||||
? dayjs.utc(backupInterval.timeOfDay, 'HH:mm').local()
|
||||
: undefined;
|
||||
|
||||
const displayedWeekday: number | undefined =
|
||||
backupInterval?.interval === IntervalType.WEEKLY &&
|
||||
backupInterval.weekday &&
|
||||
backupInterval.timeOfDay
|
||||
? getLocalWeekday(backupInterval.weekday, backupInterval.timeOfDay)
|
||||
: backupInterval?.weekday;
|
||||
|
||||
const displayedDayOfMonth: number | undefined =
|
||||
backupInterval?.interval === IntervalType.MONTHLY &&
|
||||
backupInterval.dayOfMonth &&
|
||||
backupInterval.timeOfDay
|
||||
? getLocalDayOfMonth(backupInterval.dayOfMonth, backupInterval.timeOfDay)
|
||||
: backupInterval?.dayOfMonth;
|
||||
|
||||
// mandatory-field check
|
||||
const isAllFieldsFilled =
|
||||
Boolean(editingDatabase.name) &&
|
||||
Boolean(editingDatabase.storePeriod) &&
|
||||
Boolean(backupInterval?.interval) &&
|
||||
(!backupInterval ||
|
||||
((backupInterval.interval !== IntervalType.WEEKLY || displayedWeekday) &&
|
||||
(backupInterval.interval !== IntervalType.MONTHLY || displayedDayOfMonth)));
|
||||
const isAllFieldsFilled = Boolean(editingDatabase.name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -142,119 +74,13 @@ export const EditDatabaseBaseInfoComponent = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup interval</div>
|
||||
<Select
|
||||
value={backupInterval?.interval}
|
||||
onChange={(v) => saveInterval({ interval: v })}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
options={[
|
||||
{ label: 'Hourly', value: IntervalType.HOURLY },
|
||||
{ label: 'Daily', value: IntervalType.DAILY },
|
||||
{ label: 'Weekly', value: IntervalType.WEEKLY },
|
||||
{ label: 'Monthly', value: IntervalType.MONTHLY },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{backupInterval?.interval === IntervalType.WEEKLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup weekday</div>
|
||||
<Select
|
||||
value={displayedWeekday}
|
||||
onChange={(localWeekday) => {
|
||||
if (!localWeekday) return;
|
||||
const ref = localTime ?? dayjs();
|
||||
saveInterval({ weekday: getUtcWeekday(localWeekday, ref) });
|
||||
}}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
options={weekdayOptions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupInterval?.interval === IntervalType.MONTHLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup day of month</div>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={31}
|
||||
value={displayedDayOfMonth}
|
||||
onChange={(localDom) => {
|
||||
if (!localDom) return;
|
||||
const ref = localTime ?? dayjs();
|
||||
saveInterval({ dayOfMonth: getUtcDayOfMonth(localDom, ref) });
|
||||
}}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupInterval?.interval !== IntervalType.HOURLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup time of day</div>
|
||||
<TimePicker
|
||||
value={localTime}
|
||||
format={timeFormat.format}
|
||||
use12Hours={timeFormat.use12Hours}
|
||||
allowClear={false}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
onChange={(t) => {
|
||||
if (!t) return;
|
||||
const patch: Partial<Interval> = { timeOfDay: t.utc().format('HH:mm') };
|
||||
|
||||
if (backupInterval?.interval === IntervalType.WEEKLY && displayedWeekday) {
|
||||
patch.weekday = getUtcWeekday(displayedWeekday, t);
|
||||
}
|
||||
if (backupInterval?.interval === IntervalType.MONTHLY && displayedDayOfMonth) {
|
||||
patch.dayOfMonth = getUtcDayOfMonth(displayedDayOfMonth, t);
|
||||
}
|
||||
|
||||
saveInterval(patch);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Store period</div>
|
||||
<Select
|
||||
value={editingDatabase.storePeriod}
|
||||
onChange={(v) => updateDatabase({ storePeriod: v })}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
options={[
|
||||
{ label: '1 day', value: Period.DAY },
|
||||
{ label: '1 week', value: Period.WEEK },
|
||||
{ label: '1 month', value: Period.MONTH },
|
||||
{ label: '3 months', value: Period.THREE_MONTH },
|
||||
{ label: '6 months', value: Period.SIX_MONTH },
|
||||
{ label: '1 year', value: Period.YEAR },
|
||||
{ label: '2 years', value: Period.TWO_YEARS },
|
||||
{ label: '3 years', value: Period.THREE_YEARS },
|
||||
{ label: '4 years', value: Period.FOUR_YEARS },
|
||||
{ label: '5 years', value: Period.FIVE_YEARS },
|
||||
{ label: 'Forever', value: Period.FOREVER },
|
||||
]}
|
||||
/>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="How long to keep the backups? Make sure you have enough storage space."
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex">
|
||||
{isShowCancelButton && (
|
||||
<Button danger ghost className="mr-1" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className={`${isShowCancelButton ? 'ml-1' : 'ml-auto'} mr-5`}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Button, Modal, Select, Spin } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { type Database, databaseApi } from '../../../../entity/databases';
|
||||
import { BackupNotificationType } from '../../../../entity/databases/model/BackupNotificationType';
|
||||
import { type Notifier, notifierApi } from '../../../../entity/notifiers';
|
||||
import { EditNotifierComponent } from '../../../notifiers/ui/edit/EditNotifierComponent';
|
||||
|
||||
@@ -99,35 +98,6 @@ export const EditDatabaseNotifiersComponent = ({
|
||||
You can select several notifiers, notifications will be sent to all of them.
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Sent notification when</div>
|
||||
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={editingDatabase.sendNotificationsOn}
|
||||
onChange={(sendNotificationsOn) => {
|
||||
setEditingDatabase({
|
||||
...editingDatabase,
|
||||
sendNotificationsOn,
|
||||
} as unknown as Database);
|
||||
|
||||
setIsUnsaved(true);
|
||||
}}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
options={[
|
||||
{
|
||||
label: 'Backup failed',
|
||||
value: BackupNotificationType.BACKUP_FAILED,
|
||||
},
|
||||
{
|
||||
label: 'Backup success',
|
||||
value: BackupNotificationType.BACKUP_SUCCESS,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Notifiers</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ interface Props {
|
||||
|
||||
isShowDbVersionHint?: boolean;
|
||||
isShowDbName?: boolean;
|
||||
isBlockDbName?: boolean;
|
||||
}
|
||||
|
||||
export const EditDatabaseSpecificDataComponent = ({
|
||||
@@ -43,6 +44,8 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
|
||||
isShowDbVersionHint = true,
|
||||
isShowDbName = true,
|
||||
|
||||
isBlockDbName = false,
|
||||
}: Props) => {
|
||||
const [editingDatabase, setEditingDatabase] = useState<Database>();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -263,6 +266,7 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
placeholder="Enter PG database name (optional)"
|
||||
disabled={isBlockDbName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -283,33 +287,6 @@ export const EditDatabaseSpecificDataComponent = ({
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">CPU count</div>
|
||||
<InputNumber
|
||||
value={editingDatabase.postgresql?.cpuCount}
|
||||
onChange={(e) => {
|
||||
if (!editingDatabase.postgresql || e === null) return;
|
||||
|
||||
setEditingDatabase({
|
||||
...editingDatabase,
|
||||
postgresql: { ...editingDatabase.postgresql, cpuCount: e },
|
||||
});
|
||||
setIsConnectionTested(false);
|
||||
}}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
placeholder="Enter PG CPU count"
|
||||
min={1}
|
||||
step={1}
|
||||
/>
|
||||
<Tooltip
|
||||
className="cursor-pointer"
|
||||
title="The amount of CPU can be utilized for backuping or restoring"
|
||||
>
|
||||
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import { Button, Modal, Select, Spin } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { type Database, databaseApi } from '../../../../entity/databases';
|
||||
import { type Storage, storageApi } from '../../../../entity/storages';
|
||||
import { ConfirmationComponent } from '../../../../shared/ui';
|
||||
import { EditStorageComponent } from '../../../storages/ui/edit/EditStorageComponent';
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
|
||||
isShowCancelButton?: boolean;
|
||||
onCancel: () => void;
|
||||
|
||||
isShowBackButton: boolean;
|
||||
onBack: () => void;
|
||||
|
||||
isShowSaveOnlyForUnsaved: boolean;
|
||||
saveButtonText?: string;
|
||||
isSaveToApi: boolean;
|
||||
onSaved: (database: Database) => void;
|
||||
}
|
||||
|
||||
export const EditDatabaseStorageComponent = ({
|
||||
database,
|
||||
|
||||
isShowCancelButton,
|
||||
onCancel,
|
||||
|
||||
isShowBackButton,
|
||||
onBack,
|
||||
|
||||
isShowSaveOnlyForUnsaved,
|
||||
saveButtonText,
|
||||
isSaveToApi,
|
||||
onSaved,
|
||||
}: Props) => {
|
||||
const [editingDatabase, setEditingDatabase] = useState<Database>();
|
||||
const [isUnsaved, setIsUnsaved] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [storages, setStorages] = useState<Storage[]>([]);
|
||||
const [isStoragesLoading, setIsStoragesLoading] = useState(false);
|
||||
const [isShowCreateStorage, setShowCreateStorage] = useState(false);
|
||||
|
||||
const [isShowWarn, setIsShowWarn] = useState(false);
|
||||
|
||||
const saveDatabase = async () => {
|
||||
if (!editingDatabase) return;
|
||||
|
||||
if (isSaveToApi) {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await databaseApi.updateDatabase(editingDatabase);
|
||||
setIsUnsaved(false);
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
}
|
||||
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
onSaved(editingDatabase);
|
||||
};
|
||||
|
||||
const loadStorages = async () => {
|
||||
setIsStoragesLoading(true);
|
||||
|
||||
try {
|
||||
const storages = await storageApi.getStorages();
|
||||
setStorages(storages);
|
||||
} catch (e) {
|
||||
alert((e as Error).message);
|
||||
}
|
||||
|
||||
setIsStoragesLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsSaving(false);
|
||||
setEditingDatabase({ ...database });
|
||||
loadStorages();
|
||||
|
||||
if (database.id) {
|
||||
setIsShowWarn(true);
|
||||
}
|
||||
}, [database]);
|
||||
|
||||
if (!editingDatabase) return null;
|
||||
|
||||
if (isStoragesLoading)
|
||||
return (
|
||||
<div className="mb-5 flex items-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5 max-w-[275px] text-gray-500">
|
||||
Storage - is a place where backups will be stored (local disk, S3, Google Drive, etc.)
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Storages</div>
|
||||
|
||||
<Select
|
||||
value={editingDatabase.storage.id}
|
||||
onChange={(storageId) => {
|
||||
if (storageId.includes('create-new-storage')) {
|
||||
setShowCreateStorage(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setEditingDatabase({
|
||||
...editingDatabase,
|
||||
storage: storages.find((s) => s.id === storageId),
|
||||
} as unknown as Database);
|
||||
|
||||
setIsUnsaved(true);
|
||||
}}
|
||||
size="small"
|
||||
className="max-w-[200px] grow"
|
||||
options={[
|
||||
...storages.map((s) => ({ label: s.name, value: s.id })),
|
||||
{ label: 'Create new storage', value: 'create-new-storage' },
|
||||
]}
|
||||
placeholder="Select storages"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex">
|
||||
{isShowCancelButton && (
|
||||
<Button className="mr-1" danger ghost onClick={() => onCancel()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isShowBackButton && (
|
||||
<Button className="mr-auto" type="primary" ghost onClick={() => onBack()}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(!isShowSaveOnlyForUnsaved || isUnsaved) && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => saveDatabase()}
|
||||
loading={isSaving}
|
||||
disabled={isSaving || !editingDatabase.storage.id}
|
||||
className="mr-5"
|
||||
>
|
||||
{saveButtonText || 'Save'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isShowCreateStorage && (
|
||||
<Modal
|
||||
title="Add storage"
|
||||
footer={<div />}
|
||||
open={isShowCreateStorage}
|
||||
onCancel={() => setShowCreateStorage(false)}
|
||||
>
|
||||
<div className="my-3 max-w-[275px] text-gray-500">
|
||||
Storage - is a place where backups will be stored (local disk, S3, Google Drive, etc.)
|
||||
</div>
|
||||
|
||||
<EditStorageComponent
|
||||
isShowName
|
||||
isShowClose={false}
|
||||
onClose={() => setShowCreateStorage(false)}
|
||||
onChanged={() => {
|
||||
loadStorages();
|
||||
setShowCreateStorage(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{isShowWarn && (
|
||||
<ConfirmationComponent
|
||||
onConfirm={() => {
|
||||
setIsShowWarn(false);
|
||||
}}
|
||||
onDecline={() => {
|
||||
setIsShowWarn(false);
|
||||
}}
|
||||
description="If you change the storage, all backups in this storage will be deleted."
|
||||
actionButtonColor="red"
|
||||
actionText="I understand"
|
||||
cancelText="Cancel"
|
||||
hideCancelButton
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,84 +1,11 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type Database } from '../../../../entity/databases';
|
||||
import { Period } from '../../../../entity/databases/model/Period';
|
||||
import { IntervalType } from '../../../../entity/intervals';
|
||||
import {
|
||||
getLocalDayOfMonth,
|
||||
getLocalWeekday,
|
||||
getUserTimeFormat,
|
||||
} from '../../../../shared/time/utils';
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
isShowName?: boolean;
|
||||
}
|
||||
|
||||
const weekdayLabels = {
|
||||
1: 'Mon',
|
||||
2: 'Tue',
|
||||
3: 'Wed',
|
||||
4: 'Thu',
|
||||
5: 'Fri',
|
||||
6: 'Sat',
|
||||
7: 'Sun',
|
||||
};
|
||||
|
||||
const intervalLabels = {
|
||||
[IntervalType.HOURLY]: 'Hourly',
|
||||
[IntervalType.DAILY]: 'Daily',
|
||||
[IntervalType.WEEKLY]: 'Weekly',
|
||||
[IntervalType.MONTHLY]: 'Monthly',
|
||||
};
|
||||
|
||||
const periodLabels = {
|
||||
[Period.DAY]: '1 day',
|
||||
[Period.WEEK]: '1 week',
|
||||
[Period.MONTH]: '1 month',
|
||||
[Period.THREE_MONTH]: '3 months',
|
||||
[Period.SIX_MONTH]: '6 months',
|
||||
[Period.YEAR]: '1 year',
|
||||
[Period.TWO_YEARS]: '2 years',
|
||||
[Period.THREE_YEARS]: '3 years',
|
||||
[Period.FOUR_YEARS]: '4 years',
|
||||
[Period.FIVE_YEARS]: '5 years',
|
||||
[Period.FOREVER]: 'Forever',
|
||||
};
|
||||
|
||||
export const ShowDatabaseBaseInfoComponent = ({ database, isShowName }: Props) => {
|
||||
// Detect user's preferred time format (12-hour vs 24-hour)
|
||||
const timeFormat = useMemo(() => {
|
||||
const is12Hour = getUserTimeFormat();
|
||||
return {
|
||||
use12Hours: is12Hour,
|
||||
format: is12Hour ? 'h:mm A' : 'HH:mm',
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { backupInterval } = database;
|
||||
|
||||
const localTime = backupInterval?.timeOfDay
|
||||
? dayjs.utc(backupInterval.timeOfDay, 'HH:mm').local()
|
||||
: undefined;
|
||||
|
||||
const formattedTime = localTime ? localTime.format(timeFormat.format) : '';
|
||||
|
||||
// Convert UTC weekday/day-of-month to local equivalents for display
|
||||
const displayedWeekday: number | undefined =
|
||||
backupInterval?.interval === IntervalType.WEEKLY &&
|
||||
backupInterval.weekday &&
|
||||
backupInterval.timeOfDay
|
||||
? getLocalWeekday(backupInterval.weekday, backupInterval.timeOfDay)
|
||||
: backupInterval?.weekday;
|
||||
|
||||
const displayedDayOfMonth: number | undefined =
|
||||
backupInterval?.interval === IntervalType.MONTHLY &&
|
||||
backupInterval.dayOfMonth &&
|
||||
backupInterval.timeOfDay
|
||||
? getLocalDayOfMonth(backupInterval.dayOfMonth, backupInterval.timeOfDay)
|
||||
: backupInterval?.dayOfMonth;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isShowName && (
|
||||
@@ -87,39 +14,6 @@ export const ShowDatabaseBaseInfoComponent = ({ database, isShowName }: Props) =
|
||||
<div>{database.name || ''}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup interval</div>
|
||||
<div>{backupInterval?.interval ? intervalLabels[backupInterval.interval] : ''}</div>
|
||||
</div>
|
||||
|
||||
{backupInterval?.interval === IntervalType.WEEKLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup weekday</div>
|
||||
<div>
|
||||
{displayedWeekday ? weekdayLabels[displayedWeekday as keyof typeof weekdayLabels] : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupInterval?.interval === IntervalType.MONTHLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup day of month</div>
|
||||
<div>{displayedDayOfMonth || ''}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupInterval?.interval !== IntervalType.HOURLY && (
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Backup time of day</div>
|
||||
<div>{formattedTime}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Store period</div>
|
||||
<div>{database.storePeriod ? periodLabels[database.storePeriod] : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,40 +1,27 @@
|
||||
import { type Database } from '../../../../entity/databases';
|
||||
import { BackupNotificationType } from '../../../../entity/databases/model/BackupNotificationType';
|
||||
import { getNotifierLogoFromType } from '../../../../entity/notifiers/models/getNotifierLogoFromType';
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
}
|
||||
|
||||
const notificationTypeLabels = {
|
||||
[BackupNotificationType.BACKUP_FAILED]: 'backup failed',
|
||||
[BackupNotificationType.BACKUP_SUCCESS]: 'backup success',
|
||||
};
|
||||
|
||||
export const ShowDatabaseNotifiersComponent = ({ database }: Props) => {
|
||||
const notificationLabels =
|
||||
database.sendNotificationsOn?.map((type) => notificationTypeLabels[type]).join(', ') || '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex w-full">
|
||||
<div className="min-w-[150px]">Send notification when</div>
|
||||
<div>
|
||||
{notificationLabels.split(', ').map((label) => (
|
||||
<div key={label}>- {label}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full">
|
||||
<div className="min-w-[150px]">Notify to</div>
|
||||
|
||||
<div>
|
||||
{database.notifiers?.map((notifier) => (
|
||||
<div className="flex items-center" key={notifier.id}>
|
||||
<div>- {notifier.name}</div>
|
||||
<img src={getNotifierLogoFromType(notifier?.notifierType)} className="ml-1 h-4 w-4" />
|
||||
</div>
|
||||
))}
|
||||
{database.notifiers && database.notifiers.length > 0 ? (
|
||||
database.notifiers.map((notifier) => (
|
||||
<div className="flex items-center" key={notifier.id}>
|
||||
<div>- {notifier.name}</div>
|
||||
<img src={getNotifierLogoFromType(notifier?.notifierType)} className="ml-1 h-4 w-4" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-gray-500">No notifiers configured</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,11 +64,6 @@ export const ShowDatabaseSpecificDataComponent = ({ database }: Props) => {
|
||||
<div className="min-w-[150px]">Use HTTPS</div>
|
||||
<div>{database.postgresql?.isHttps ? 'Yes' : 'No'}</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex w-full items-center">
|
||||
<div className="min-w-[150px]">CPU count</div>
|
||||
<div>{database.postgresql?.cpuCount || ''}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { type Database } from '../../../../entity/databases';
|
||||
import { getStorageLogoFromType } from '../../../../entity/storages/models/getStorageLogoFromType';
|
||||
|
||||
interface Props {
|
||||
database: Database;
|
||||
}
|
||||
|
||||
export const ShowDatabaseStorageComponent = ({ database }: Props) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-5 flex w-full items-center">
|
||||
<div className="min-w-[150px]">Storage</div>
|
||||
<div>{database.storage?.name || ''}</div>{' '}
|
||||
<img
|
||||
src={getStorageLogoFromType(database.storage?.type)}
|
||||
alt="storageIcon"
|
||||
className="ml-1 h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,11 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import type { Database } from '../../../entity/databases';
|
||||
import { HealthStatus } from '../../../entity/databases/model/HealthStatus';
|
||||
import { type HealthcheckAttempt, healthcheckAttemptApi } from '../../../entity/healthcheck';
|
||||
import {
|
||||
type HealthcheckAttempt,
|
||||
healthcheckAttemptApi,
|
||||
healthcheckConfigApi,
|
||||
} from '../../../entity/healthcheck';
|
||||
import { getUserShortTimeFormat } from '../../../shared/time/getUserTimeFormat';
|
||||
|
||||
interface Props {
|
||||
@@ -36,6 +40,9 @@ const getAfterDateByPeriod = (period: 'today' | '7d' | '30d' | 'all'): Date => {
|
||||
};
|
||||
|
||||
export const HealthckeckAttemptsComponent = ({ database }: Props) => {
|
||||
const [isHealthcheckConfigLoading, setIsHealthcheckConfigLoading] = useState(false);
|
||||
const [isShowHealthcheckConfig, setIsShowHealthcheckConfig] = useState(false);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [healthcheckAttempts, setHealthcheckAttempts] = useState<HealthcheckAttempt[]>([]);
|
||||
const [period, setPeriod] = useState<'today' | '7d' | '30d' | 'all'>('today');
|
||||
@@ -71,21 +78,45 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadHealthcheckAttempts();
|
||||
}, [database, period]);
|
||||
let isHealthcheckEnabled = false;
|
||||
|
||||
setIsHealthcheckConfigLoading(true);
|
||||
healthcheckConfigApi.getHealthcheckConfig(database.id).then((healthcheckConfig) => {
|
||||
setIsHealthcheckConfigLoading(false);
|
||||
|
||||
if (healthcheckConfig.isHealthcheckEnabled) {
|
||||
isHealthcheckEnabled = true;
|
||||
setIsShowHealthcheckConfig(true);
|
||||
|
||||
loadHealthcheckAttempts();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (period === 'today') {
|
||||
const interval = setInterval(() => {
|
||||
loadHealthcheckAttempts(false);
|
||||
}, 60_000); // 1 minute
|
||||
if (isHealthcheckEnabled) {
|
||||
const interval = setInterval(() => {
|
||||
loadHealthcheckAttempts(false);
|
||||
}, 60_000); // 1 minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}
|
||||
}, [period]);
|
||||
|
||||
if (isHealthcheckConfigLoading) {
|
||||
return (
|
||||
<div className="mb-5 flex items-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isShowHealthcheckConfig) {
|
||||
return <div />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-5 w-full rounded bg-white p-5 shadow">
|
||||
<h2 className="text-xl font-bold">Healthcheck attempts</h2>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
@@ -111,7 +142,7 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex max-w-[750px] flex-wrap gap-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{healthcheckAttempts.length > 0 ? (
|
||||
healthcheckAttempts.map((healthcheckAttempt) => (
|
||||
<Tooltip
|
||||
@@ -128,7 +159,7 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
|
||||
</Tooltip>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">No data</div>
|
||||
<div className="text-xs text-gray-400">No data yet</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -108,6 +108,7 @@ export const RestoresComponent = ({ database, backup }: Props) => {
|
||||
restore(database);
|
||||
}}
|
||||
isShowDbVersionHint={false}
|
||||
isBlockDbName
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button, Input, Spin } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { databaseApi } from '../../entity/databases';
|
||||
import { backupConfigApi } from '../../entity/backups';
|
||||
import { storageApi } from '../../entity/storages';
|
||||
import type { Storage } from '../../entity/storages';
|
||||
import { ToastHelper } from '../../shared/toast';
|
||||
@@ -63,7 +63,7 @@ export const StorageComponent = ({ storageId, onStorageChanged, onStorageDeleted
|
||||
setIsRemoving(true);
|
||||
|
||||
try {
|
||||
const isStorageUsing = await databaseApi.isStorageUsing(storage.id);
|
||||
const isStorageUsing = await backupConfigApi.isStorageUsing(storage.id);
|
||||
if (isStorageUsing) {
|
||||
alert('Storage is used by some databases. Please remove the storage from databases first.');
|
||||
setIsShowRemoveConfirm(false);
|
||||
@@ -260,7 +260,7 @@ export const StorageComponent = ({ storageId, onStorageChanged, onStorageDeleted
|
||||
<ConfirmationComponent
|
||||
onConfirm={remove}
|
||||
onDecline={() => setIsShowRemoveConfirm(false)}
|
||||
description="Are you sure you want to remove this storage? This action cannot be undone."
|
||||
description="Are you sure you want to remove this storage? This action cannot be undone. If some backups are using this storage, they will be removed too."
|
||||
actionText="Remove"
|
||||
actionButtonColor="red"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user