From aa6b495cfff0bb56f869f8f4a785ae19488d4d07 Mon Sep 17 00:00:00 2001 From: Rostislav Dugin Date: Tue, 8 Jul 2025 22:04:20 +0300 Subject: [PATCH] FEATURE (backups): Move backups to separate backup config and make feature optional --- README.md | 2 +- assets/healthchecks.svg | 317 +++++++----- backend/.env.development.example | 2 +- backend/.gitignore | 3 +- backend/cmd/main.go | 7 +- .../{ => backups}/background_service.go | 43 +- .../backups/{ => backups}/controller.go | 0 .../features/backups/{ => backups}/di.go | 12 +- .../features/backups/{ => backups}/enums.go | 1 - .../backups/{ => backups}/interfaces.go | 6 + .../features/backups/{ => backups}/mocks.go | 0 .../features/backups/{ => backups}/model.go | 0 .../backups/{ => backups}/repository.go | 35 ++ .../features/backups/{ => backups}/service.go | 141 +++-- .../backups/{ => backups}/service_test.go | 10 + .../usecases/create_backup_uc.go | 5 +- .../backups/{ => backups}/usecases/di.go | 2 +- .../usecases/postgresql/create_backup_uc.go | 10 +- .../{ => backups}/usecases/postgresql/di.go | 0 .../usecases/postgresql/interfaces.go | 0 .../features/backups/config/controller.go | 144 ++++++ .../internal/features/backups/config/di.go | 27 + .../internal/features/backups/config/enums.go | 8 + .../features/backups/config/interfaces.go | 7 + .../internal/features/backups/config/model.go | 85 +++ .../features/backups/config/repository.go | 104 ++++ .../features/backups/config/service.go | 167 ++++++ .../features/backups/config/testing.go | 40 ++ .../internal/features/databases/controller.go | 50 +- .../databases/databases/postgresql/model.go | 2 - backend/internal/features/databases/di.go | 4 +- backend/internal/features/databases/enums.go | 55 -- .../internal/features/databases/interfaces.go | 8 +- backend/internal/features/databases/model.go | 68 +-- .../internal/features/databases/repository.go | 51 +- .../internal/features/databases/service.go | 55 +- .../internal/features/databases/testing.go | 22 +- .../healthcheck/attempt/check_pg_health_uc.go | 3 +- .../attempt/check_pg_health_uc_test.go | 14 +- backend/internal/features/restores/di.go | 8 +- .../features/restores/models/model.go | 2 +- backend/internal/features/restores/service.go | 33 +- .../usecases/postgresql/restore_backup_uc.go | 6 +- .../restores/usecases/restore_backup_uc.go | 11 +- .../storages/models/google_drive/model.go | 108 +++- .../features/system/healthcheck/di.go | 2 +- .../features/system/healthcheck/service.go | 2 +- .../tests/postgresql_backup_restore_test.go | 30 +- backend/internal/util/period/enums.go | 49 ++ .../20250710122028_create_backup_config.sql | 94 ++++ .../src/entity/backups/api/backupConfigApi.ts | 35 ++ frontend/src/entity/backups/index.ts | 3 + .../src/entity/backups/model/BackupConfig.ts | 15 + .../backups/model/BackupNotificationType.ts | 4 + .../src/entity/databases/api/databaseApi.ts | 13 - .../databases/model/BackupNotificationType.ts | 4 - .../src/entity/databases/model/Database.ts | 10 - .../model/postgresql/PostgresqlDatabase.ts | 2 - frontend/src/features/backups/index.ts | 2 + .../features/backups/ui/BackupsComponent.tsx | 46 +- .../backups/ui/EditBackupConfigComponent.tsx | 483 ++++++++++++++++++ .../backups/ui/ShowBackupConfigComponent.tsx | 172 +++++++ .../databases/ui/CreateDatabaseComponent.tsx | 31 +- .../databases/ui/DatabaseCardComponent.tsx | 11 - .../databases/ui/DatabaseComponent.tsx | 153 ++---- .../ui/edit/EditDatabaseBaseInfoComponent.tsx | 182 +------ .../edit/EditDatabaseNotifiersComponent.tsx | 30 -- .../EditDatabaseSpecificDataComponent.tsx | 31 +- .../ui/edit/EditDatabaseStorageComponent.tsx | 199 -------- .../ui/show/ShowDatabaseBaseInfoComponent.tsx | 106 ---- .../show/ShowDatabaseNotifiersComponent.tsx | 35 +- .../ShowDatabaseSpecificDataComponent.tsx | 5 - .../ui/show/ShowDatabaseStorageComponent.tsx | 22 - .../ui/HealthckeckAttemptsComponent.tsx | 53 +- .../restores/ui/RestoresComponent.tsx | 1 + .../features/storages/StorageComponent.tsx | 6 +- 76 files changed, 2254 insertions(+), 1255 deletions(-) rename backend/internal/features/backups/{ => backups}/background_service.go (75%) rename backend/internal/features/backups/{ => backups}/controller.go (100%) rename backend/internal/features/backups/{ => backups}/di.go (76%) rename backend/internal/features/backups/{ => backups}/enums.go (80%) rename backend/internal/features/backups/{ => backups}/interfaces.go (72%) rename backend/internal/features/backups/{ => backups}/mocks.go (100%) rename backend/internal/features/backups/{ => backups}/model.go (100%) rename backend/internal/features/backups/{ => backups}/repository.go (78%) rename backend/internal/features/backups/{ => backups}/service.go (69%) rename backend/internal/features/backups/{ => backups}/service_test.go (90%) rename backend/internal/features/backups/{ => backups}/usecases/create_backup_uc.go (83%) rename backend/internal/features/backups/{ => backups}/usecases/di.go (89%) rename backend/internal/features/backups/{ => backups}/usecases/postgresql/create_backup_uc.go (97%) rename backend/internal/features/backups/{ => backups}/usecases/postgresql/di.go (100%) rename backend/internal/features/backups/{ => backups}/usecases/postgresql/interfaces.go (100%) create mode 100644 backend/internal/features/backups/config/controller.go create mode 100644 backend/internal/features/backups/config/di.go create mode 100644 backend/internal/features/backups/config/enums.go create mode 100644 backend/internal/features/backups/config/interfaces.go create mode 100644 backend/internal/features/backups/config/model.go create mode 100644 backend/internal/features/backups/config/repository.go create mode 100644 backend/internal/features/backups/config/service.go create mode 100644 backend/internal/features/backups/config/testing.go create mode 100644 backend/internal/util/period/enums.go create mode 100644 backend/migrations/20250710122028_create_backup_config.sql create mode 100644 frontend/src/entity/backups/api/backupConfigApi.ts create mode 100644 frontend/src/entity/backups/model/BackupConfig.ts create mode 100644 frontend/src/entity/backups/model/BackupNotificationType.ts delete mode 100644 frontend/src/entity/databases/model/BackupNotificationType.ts create mode 100644 frontend/src/features/backups/ui/EditBackupConfigComponent.tsx create mode 100644 frontend/src/features/backups/ui/ShowBackupConfigComponent.tsx delete mode 100644 frontend/src/features/databases/ui/edit/EditDatabaseStorageComponent.tsx delete mode 100644 frontend/src/features/databases/ui/show/ShowDatabaseStorageComponent.tsx diff --git a/README.md b/README.md index 5b285ce..32ff707 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Postgresus Logo

PostgreSQL monitoring and backup

-

Free, open source and self-hosted solution for automated PostgreSQL monitoring and backups with multiple storage options and notifications

+

Free, open source and self-hosted solution for automated PostgreSQL monitoring and backups. With multiple storage options and notifications

Features • diff --git a/assets/healthchecks.svg b/assets/healthchecks.svg index 702d710..6dd3037 100644 --- a/assets/healthchecks.svg +++ b/assets/healthchecks.svg @@ -1,11 +1,22 @@ - - - - - - - + + + + + + + + + + + + + + + + + + @@ -24,24 +35,35 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -60,24 +82,35 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -96,24 +129,35 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -132,24 +176,35 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -168,24 +223,35 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -204,24 +270,35 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -237,22 +314,22 @@ - - - - - - - - - - - - - - + + + - - - + + + + + + + + + + + + + + diff --git a/backend/.env.development.example b/backend/.env.development.example index 3734a19..fa7e712 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -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 diff --git a/backend/.gitignore b/backend/.gitignore index 5d8760c..d10186d 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -10,4 +10,5 @@ swagger/docs.go swagger/swagger.json swagger/swagger.yaml postgresus-backend.exe -ui/build/* \ No newline at end of file +ui/build/* +pgdata-for-restore/ \ No newline at end of file diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 38aede9..85c9ec2 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -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() } diff --git a/backend/internal/features/backups/background_service.go b/backend/internal/features/backups/backups/background_service.go similarity index 75% rename from backend/internal/features/backups/background_service.go rename to backend/internal/features/backups/backups/background_service.go index 3e1c8f0..548554d 100644 --- a/backend/internal/features/backups/background_service.go +++ b/backend/internal/features/backups/backups/background_service.go @@ -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) diff --git a/backend/internal/features/backups/controller.go b/backend/internal/features/backups/backups/controller.go similarity index 100% rename from backend/internal/features/backups/controller.go rename to backend/internal/features/backups/backups/controller.go diff --git a/backend/internal/features/backups/di.go b/backend/internal/features/backups/backups/di.go similarity index 76% rename from backend/internal/features/backups/di.go rename to backend/internal/features/backups/backups/di.go index 023994b..d6074e7 100644 --- a/backend/internal/features/backups/di.go +++ b/backend/internal/features/backups/backups/di.go @@ -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 { diff --git a/backend/internal/features/backups/enums.go b/backend/internal/features/backups/backups/enums.go similarity index 80% rename from backend/internal/features/backups/enums.go rename to backend/internal/features/backups/backups/enums.go index 3c1cb4a..ef06b19 100644 --- a/backend/internal/features/backups/enums.go +++ b/backend/internal/features/backups/backups/enums.go @@ -6,5 +6,4 @@ const ( BackupStatusInProgress BackupStatus = "IN_PROGRESS" BackupStatusCompleted BackupStatus = "COMPLETED" BackupStatusFailed BackupStatus = "FAILED" - BackupStatusDeleted BackupStatus = "DELETED" ) diff --git a/backend/internal/features/backups/interfaces.go b/backend/internal/features/backups/backups/interfaces.go similarity index 72% rename from backend/internal/features/backups/interfaces.go rename to backend/internal/features/backups/backups/interfaces.go index 2bd8443..b4b7fb6 100644 --- a/backend/internal/features/backups/interfaces.go +++ b/backend/internal/features/backups/backups/interfaces.go @@ -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 +} diff --git a/backend/internal/features/backups/mocks.go b/backend/internal/features/backups/backups/mocks.go similarity index 100% rename from backend/internal/features/backups/mocks.go rename to backend/internal/features/backups/backups/mocks.go diff --git a/backend/internal/features/backups/model.go b/backend/internal/features/backups/backups/model.go similarity index 100% rename from backend/internal/features/backups/model.go rename to backend/internal/features/backups/backups/model.go diff --git a/backend/internal/features/backups/repository.go b/backend/internal/features/backups/backups/repository.go similarity index 78% rename from backend/internal/features/backups/repository.go rename to backend/internal/features/backups/backups/repository.go index c9bb0a1..dc75113 100644 --- a/backend/internal/features/backups/repository.go +++ b/backend/internal/features/backups/backups/repository.go @@ -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 } diff --git a/backend/internal/features/backups/service.go b/backend/internal/features/backups/backups/service.go similarity index 69% rename from backend/internal/features/backups/service.go rename to backend/internal/features/backups/backups/service.go index bdef57b..37f6ebe 100644 --- a/backend/internal/features/backups/service.go +++ b/backend/internal/features/backups/backups/service.go @@ -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 +} diff --git a/backend/internal/features/backups/service_test.go b/backend/internal/features/backups/backups/service_test.go similarity index 90% rename from backend/internal/features/backups/service_test.go rename to backend/internal/features/backups/backups/service_test.go index 897e7a9..b0d0205 100644 --- a/backend/internal/features/backups/service_test.go +++ b/backend/internal/features/backups/backups/service_test.go @@ -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( diff --git a/backend/internal/features/backups/usecases/create_backup_uc.go b/backend/internal/features/backups/backups/usecases/create_backup_uc.go similarity index 83% rename from backend/internal/features/backups/usecases/create_backup_uc.go rename to backend/internal/features/backups/backups/usecases/create_backup_uc.go index b75fe5d..75d181d 100644 --- a/backend/internal/features/backups/usecases/create_backup_uc.go +++ b/backend/internal/features/backups/backups/usecases/create_backup_uc.go @@ -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, diff --git a/backend/internal/features/backups/usecases/di.go b/backend/internal/features/backups/backups/usecases/di.go similarity index 89% rename from backend/internal/features/backups/usecases/di.go rename to backend/internal/features/backups/backups/usecases/di.go index 69fb3c1..0bda57c 100644 --- a/backend/internal/features/backups/usecases/di.go +++ b/backend/internal/features/backups/backups/usecases/di.go @@ -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{ diff --git a/backend/internal/features/backups/usecases/postgresql/create_backup_uc.go b/backend/internal/features/backups/backups/usecases/postgresql/create_backup_uc.go similarity index 97% rename from backend/internal/features/backups/usecases/postgresql/create_backup_uc.go rename to backend/internal/features/backups/backups/usecases/postgresql/create_backup_uc.go index 470e670..383f3b2 100644 --- a/backend/internal/features/backups/usecases/postgresql/create_backup_uc.go +++ b/backend/internal/features/backups/backups/usecases/postgresql/create_backup_uc.go @@ -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 diff --git a/backend/internal/features/backups/usecases/postgresql/di.go b/backend/internal/features/backups/backups/usecases/postgresql/di.go similarity index 100% rename from backend/internal/features/backups/usecases/postgresql/di.go rename to backend/internal/features/backups/backups/usecases/postgresql/di.go diff --git a/backend/internal/features/backups/usecases/postgresql/interfaces.go b/backend/internal/features/backups/backups/usecases/postgresql/interfaces.go similarity index 100% rename from backend/internal/features/backups/usecases/postgresql/interfaces.go rename to backend/internal/features/backups/backups/usecases/postgresql/interfaces.go diff --git a/backend/internal/features/backups/config/controller.go b/backend/internal/features/backups/config/controller.go new file mode 100644 index 0000000..9759652 --- /dev/null +++ b/backend/internal/features/backups/config/controller.go @@ -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}) +} diff --git a/backend/internal/features/backups/config/di.go b/backend/internal/features/backups/config/di.go new file mode 100644 index 0000000..575126c --- /dev/null +++ b/backend/internal/features/backups/config/di.go @@ -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 +} diff --git a/backend/internal/features/backups/config/enums.go b/backend/internal/features/backups/config/enums.go new file mode 100644 index 0000000..b37455f --- /dev/null +++ b/backend/internal/features/backups/config/enums.go @@ -0,0 +1,8 @@ +package backups_config + +type BackupNotificationType string + +const ( + NotificationBackupFailed BackupNotificationType = "BACKUP_FAILED" + NotificationBackupSuccess BackupNotificationType = "BACKUP_SUCCESS" +) diff --git a/backend/internal/features/backups/config/interfaces.go b/backend/internal/features/backups/config/interfaces.go new file mode 100644 index 0000000..b9f6cbf --- /dev/null +++ b/backend/internal/features/backups/config/interfaces.go @@ -0,0 +1,7 @@ +package backups_config + +import "github.com/google/uuid" + +type BackupConfigStorageChangeListener interface { + OnBeforeBackupsStorageChange(dbID uuid.UUID, storageID uuid.UUID) error +} diff --git a/backend/internal/features/backups/config/model.go b/backend/internal/features/backups/config/model.go new file mode 100644 index 0000000..bb66d7c --- /dev/null +++ b/backend/internal/features/backups/config/model.go @@ -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 +} diff --git a/backend/internal/features/backups/config/repository.go b/backend/internal/features/backups/config/repository.go new file mode 100644 index 0000000..3d7c487 --- /dev/null +++ b/backend/internal/features/backups/config/repository.go @@ -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 +} diff --git a/backend/internal/features/backups/config/service.go b/backend/internal/features/backups/config/service.go new file mode 100644 index 0000000..e2670ee --- /dev/null +++ b/backend/internal/features/backups/config/service.go @@ -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 +} diff --git a/backend/internal/features/backups/config/testing.go b/backend/internal/features/backups/config/testing.go new file mode 100644 index 0000000..8a4c010 --- /dev/null +++ b/backend/internal/features/backups/config/testing.go @@ -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 +} diff --git a/backend/internal/features/databases/controller.go b/backend/internal/features/databases/controller.go index 61da44f..2ed4738 100644 --- a/backend/internal/features/databases/controller.go +++ b/backend/internal/features/databases/controller.go @@ -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 diff --git a/backend/internal/features/databases/databases/postgresql/model.go b/backend/internal/features/databases/databases/postgresql/model.go index 3be61cb..0a3eafd 100644 --- a/backend/internal/features/databases/databases/postgresql/model.go +++ b/backend/internal/features/databases/databases/postgresql/model.go @@ -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 { diff --git a/backend/internal/features/databases/di.go b/backend/internal/features/databases/di.go index 38ba4ec..59d067c 100644 --- a/backend/internal/features/databases/di.go +++ b/backend/internal/features/databases/di.go @@ -12,8 +12,8 @@ var databaseService = &DatabaseService{ databaseRepository, notifiers.GetNotifierService(), logger.GetLogger(), - nil, - nil, + []DatabaseCreationListener{}, + []DatabaseRemoveListener{}, } var databaseController = &DatabaseController{ diff --git a/backend/internal/features/databases/enums.go b/backend/internal/features/databases/enums.go index dafe700..722115d 100644 --- a/backend/internal/features/databases/enums.go +++ b/backend/internal/features/databases/enums.go @@ -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 ( diff --git a/backend/internal/features/databases/interfaces.go b/backend/internal/features/databases/interfaces.go index d8e92c0..ba63d6a 100644 --- a/backend/internal/features/databases/interfaces.go +++ b/backend/internal/features/databases/interfaces.go @@ -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 +} diff --git a/backend/internal/features/databases/model.go b/backend/internal/features/databases/model.go index 2c3297d..89fca79 100644 --- a/backend/internal/features/databases/model.go +++ b/backend/internal/features/databases/model.go @@ -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() diff --git a/backend/internal/features/databases/repository.go b/backend/internal/features/databases/repository.go index 2cef290..7cd827d 100644 --- a/backend/internal/features/databases/repository.go +++ b/backend/internal/features/databases/repository.go @@ -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 diff --git a/backend/internal/features/databases/service.go b/backend/internal/features/databases/service.go index 8d42226..c069670 100644 --- a/backend/internal/features/databases/service.go +++ b/backend/internal/features/databases/service.go @@ -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( diff --git a/backend/internal/features/databases/testing.go b/backend/internal/features/databases/testing.go index 4401694..5c767e4 100644 --- a/backend/internal/features/databases/testing.go +++ b/backend/internal/features/databases/testing.go @@ -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) diff --git a/backend/internal/features/healthcheck/attempt/check_pg_health_uc.go b/backend/internal/features/healthcheck/attempt/check_pg_health_uc.go index 47d3ff8..692a1af 100644 --- a/backend/internal/features/healthcheck/attempt/check_pg_health_uc.go +++ b/backend/internal/features/healthcheck/attempt/check_pg_health_uc.go @@ -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) diff --git a/backend/internal/features/healthcheck/attempt/check_pg_health_uc_test.go b/backend/internal/features/healthcheck/attempt/check_pg_health_uc_test.go index c01130b..66af447 100644 --- a/backend/internal/features/healthcheck/attempt/check_pg_health_uc_test.go +++ b/backend/internal/features/healthcheck/attempt/check_pg_health_uc_test.go @@ -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), ) }) diff --git a/backend/internal/features/restores/di.go b/backend/internal/features/restores/di.go index fb3e576..d107fe8 100644 --- a/backend/internal/features/restores/di.go +++ b/backend/internal/features/restores/di.go @@ -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) +} diff --git a/backend/internal/features/restores/models/model.go b/backend/internal/features/restores/models/model.go index 529236e..acaf46b 100644 --- a/backend/internal/features/restores/models/model.go +++ b/backend/internal/features/restores/models/model.go @@ -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" diff --git a/backend/internal/features/restores/service.go b/backend/internal/features/restores/service.go index 55e51a4..cb08b6f 100644 --- a/backend/internal/features/restores/service.go +++ b/backend/internal/features/restores/service.go @@ -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, diff --git a/backend/internal/features/restores/usecases/postgresql/restore_backup_uc.go b/backend/internal/features/restores/usecases/postgresql/restore_backup_uc.go index 928169b..78e2603 100644 --- a/backend/internal/features/restores/usecases/postgresql/restore_backup_uc.go +++ b/backend/internal/features/restores/usecases/postgresql/restore_backup_uc.go @@ -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) diff --git a/backend/internal/features/restores/usecases/restore_backup_uc.go b/backend/internal/features/restores/usecases/restore_backup_uc.go index 046c47f..b940878 100644 --- a/backend/internal/features/restores/usecases/restore_backup_uc.go +++ b/backend/internal/features/restores/usecases/restore_backup_uc.go @@ -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") diff --git a/backend/internal/features/storages/models/google_drive/model.go b/backend/internal/features/storages/models/google_drive/model.go index 553ae8e..b0c3ad1 100644 --- a/backend/internal/features/storages/models/google_drive/model.go +++ b/backend/internal/features/storages/models/google_drive/model.go @@ -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 +} diff --git a/backend/internal/features/system/healthcheck/di.go b/backend/internal/features/system/healthcheck/di.go index df93993..48aaa00 100644 --- a/backend/internal/features/system/healthcheck/di.go +++ b/backend/internal/features/system/healthcheck/di.go @@ -1,7 +1,7 @@ package system_healthcheck import ( - "postgresus-backend/internal/features/backups" + "postgresus-backend/internal/features/backups/backups" "postgresus-backend/internal/features/disk" ) diff --git a/backend/internal/features/system/healthcheck/service.go b/backend/internal/features/system/healthcheck/service.go index 87a29fd..5f4baf2 100644 --- a/backend/internal/features/system/healthcheck/service.go +++ b/backend/internal/features/system/healthcheck/service.go @@ -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" ) diff --git a/backend/internal/features/tests/postgresql_backup_restore_test.go b/backend/internal/features/tests/postgresql_backup_restore_test.go index 922fcaf..14544a9 100644 --- a/backend/internal/features/tests/postgresql_backup_restore_test.go +++ b/backend/internal/features/tests/postgresql_backup_restore_test.go @@ -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 diff --git a/backend/internal/util/period/enums.go b/backend/internal/util/period/enums.go new file mode 100644 index 0000000..e9ebfd4 --- /dev/null +++ b/backend/internal/util/period/enums.go @@ -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)) + } +} diff --git a/backend/migrations/20250710122028_create_backup_config.sql b/backend/migrations/20250710122028_create_backup_config.sql new file mode 100644 index 0000000..120147f --- /dev/null +++ b/backend/migrations/20250710122028_create_backup_config.sql @@ -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 diff --git a/frontend/src/entity/backups/api/backupConfigApi.ts b/frontend/src/entity/backups/api/backupConfigApi.ts new file mode 100644 index 0000000..342e156 --- /dev/null +++ b/frontend/src/entity/backups/api/backupConfigApi.ts @@ -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( + `${getApplicationServer()}/api/v1/backup-configs/save`, + requestOptions, + ); + }, + + async getBackupConfigByDbID(databaseId: string) { + return apiHelper.fetchGetJson( + `${getApplicationServer()}/api/v1/backup-configs/database/${databaseId}`, + undefined, + true, + ); + }, + + async isStorageUsing(storageId: string): Promise { + return await apiHelper + .fetchGetJson<{ + isUsing: boolean; + }>( + `${getApplicationServer()}/api/v1/backup-configs/storage/${storageId}/is-using`, + undefined, + true, + ) + .then((res) => res.isUsing); + }, +}; diff --git a/frontend/src/entity/backups/index.ts b/frontend/src/entity/backups/index.ts index 08e1b87..ccf3c2a 100644 --- a/frontend/src/entity/backups/index.ts +++ b/frontend/src/entity/backups/index.ts @@ -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'; diff --git a/frontend/src/entity/backups/model/BackupConfig.ts b/frontend/src/entity/backups/model/BackupConfig.ts new file mode 100644 index 0000000..b40a79d --- /dev/null +++ b/frontend/src/entity/backups/model/BackupConfig.ts @@ -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; +} diff --git a/frontend/src/entity/backups/model/BackupNotificationType.ts b/frontend/src/entity/backups/model/BackupNotificationType.ts new file mode 100644 index 0000000..797e4b7 --- /dev/null +++ b/frontend/src/entity/backups/model/BackupNotificationType.ts @@ -0,0 +1,4 @@ +export enum BackupNotificationType { + BackupFailed = 'BACKUP_FAILED', + BackupSuccess = 'BACKUP_SUCCESS', +} diff --git a/frontend/src/entity/databases/api/databaseApi.ts b/frontend/src/entity/databases/api/databaseApi.ts index 2ce5a28..1842277 100644 --- a/frontend/src/entity/databases/api/databaseApi.ts +++ b/frontend/src/entity/databases/api/databaseApi.ts @@ -77,17 +77,4 @@ export const databaseApi = { ) .then((res) => res.isUsing); }, - - async isStorageUsing(storageId: string): Promise { - const requestOptions: RequestOptions = new RequestOptions(); - return apiHelper - .fetchGetJson<{ - isUsing: boolean; - }>( - `${getApplicationServer()}/api/v1/databases/storage/${storageId}/is-using`, - requestOptions, - true, - ) - .then((res) => res.isUsing); - }, }; diff --git a/frontend/src/entity/databases/model/BackupNotificationType.ts b/frontend/src/entity/databases/model/BackupNotificationType.ts deleted file mode 100644 index 3accac7..0000000 --- a/frontend/src/entity/databases/model/BackupNotificationType.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum BackupNotificationType { - BACKUP_FAILED = 'BACKUP_FAILED', - BACKUP_SUCCESS = 'BACKUP_SUCCESS', -} diff --git a/frontend/src/entity/databases/model/Database.ts b/frontend/src/entity/databases/model/Database.ts index baa81ca..ec7afad 100644 --- a/frontend/src/entity/databases/model/Database.ts +++ b/frontend/src/entity/databases/model/Database.ts @@ -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; diff --git a/frontend/src/entity/databases/model/postgresql/PostgresqlDatabase.ts b/frontend/src/entity/databases/model/postgresql/PostgresqlDatabase.ts index 67a8ce3..ad1f592 100644 --- a/frontend/src/entity/databases/model/postgresql/PostgresqlDatabase.ts +++ b/frontend/src/entity/databases/model/postgresql/PostgresqlDatabase.ts @@ -11,6 +11,4 @@ export interface PostgresqlDatabase { password: string; database?: string; isHttps: boolean; - - cpuCount: number; } diff --git a/frontend/src/features/backups/index.ts b/frontend/src/features/backups/index.ts index d2e5630..fac95ab 100644 --- a/frontend/src/features/backups/index.ts +++ b/frontend/src/features/backups/index.ts @@ -1 +1,3 @@ export { BackupsComponent } from './ui/BackupsComponent'; +export { EditBackupConfigComponent } from './ui/EditBackupConfigComponent'; +export { ShowBackupConfigComponent } from './ui/ShowBackupConfigComponent'; diff --git a/frontend/src/features/backups/ui/BackupsComponent.tsx b/frontend/src/features/backups/ui/BackupsComponent.tsx index 66dad59..8a5bc5e 100644 --- a/frontend/src/features/backups/ui/BackupsComponent.tsx +++ b/frontend/src/features/backups/ui/BackupsComponent.tsx @@ -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([]); + const [isBackupConfigLoading, setIsBackupConfigLoading] = useState(false); + const [isShowBackupConfig, setIsShowBackupConfig] = useState(false); + const [isMakeBackupRequestLoading, setIsMakeBackupRequestLoading] = useState(false); const [showingBackupError, setShowingBackupError] = useState(); @@ -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 ( +

+ +
+ ); + } + + if (!isShowBackupConfig) { + return
; + } + return ( -
+

Backups

@@ -288,7 +318,7 @@ export const BackupsComponent = ({ database }: Props) => { columns={columns} dataSource={backups} rowKey="id" - loading={isLoading} + loading={isBackupsLoading} size="small" pagination={false} /> diff --git a/frontend/src/features/backups/ui/EditBackupConfigComponent.tsx b/frontend/src/features/backups/ui/EditBackupConfigComponent.tsx new file mode 100644 index 0000000..5274a40 --- /dev/null +++ b/frontend/src/features/backups/ui/EditBackupConfigComponent.tsx @@ -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(); + const [isUnsaved, setIsUnsaved] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const [storages, setStorages] = useState([]); + 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) => { + setBackupConfig((prev) => (prev ? { ...prev, ...patch } : prev)); + setIsUnsaved(true); + }; + + const saveInterval = (patch: Partial) => { + 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
; + + if (isStoragesLoading) { + return ( +
+ +
+ ); + } + + 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 ( +
+
+
Backups enabled
+ updateBackupConfig({ isBackupsEnabled: checked })} + size="small" + /> +
+ + {backupConfig.isBackupsEnabled && ( + <> +
+
Backup interval
+ { + if (!localWeekday) return; + const ref = localTime ?? dayjs(); + saveInterval({ weekday: getUtcWeekday(localWeekday, ref) }); + }} + size="small" + className="max-w-[200px] grow" + options={weekdayOptions} + /> +
+ )} + + {backupInterval?.interval === IntervalType.MONTHLY && ( +
+
Backup day of month
+ { + if (!localDom) return; + const ref = localTime ?? dayjs(); + saveInterval({ dayOfMonth: getUtcDayOfMonth(localDom, ref) }); + }} + size="small" + className="max-w-[200px] grow" + /> +
+ )} + + {backupInterval?.interval !== IntervalType.HOURLY && ( +
+
Backup time of day
+ { + if (!t) return; + const patch: Partial = { 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); + }} + /> +
+ )} + +
+
CPU count
+ updateBackupConfig({ cpuCount: value || 1 })} + size="small" + className="max-w-[200px] grow" + /> + + + + +
+ +
+
Store period
+ { + 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 && ( + storageIcon + )} +
+ +
+
Notifications
+
+ { + 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 + + + { + 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 + +
+
+ + )} + +
+ {isShowBackButton && ( + + )} + + {isShowCancelButton && ( + + )} + + +
+ + {isShowCreateStorage && ( + } + open={isShowCreateStorage} + onCancel={() => setShowCreateStorage(false)} + > +
+ Storage - is a place where backups will be stored (local disk, S3, Google Drive, etc.) +
+ + setShowCreateStorage(false)} + onChanged={() => { + loadStorages(); + setShowCreateStorage(false); + }} + /> +
+ )} + + {isShowWarn && ( + { + 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 + /> + )} +
+ ); +}; diff --git a/frontend/src/features/backups/ui/ShowBackupConfigComponent.tsx b/frontend/src/features/backups/ui/ShowBackupConfigComponent.tsx new file mode 100644 index 0000000..03a61bb --- /dev/null +++ b/frontend/src/features/backups/ui/ShowBackupConfigComponent.tsx @@ -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(); + + // 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
; + + 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 ( +
+
+
Backups enabled
+
{backupConfig.isBackupsEnabled ? 'Yes' : 'No'}
+
+ + {backupConfig.isBackupsEnabled ? ( + <> +
+
Backup interval
+
{backupInterval?.interval ? intervalLabels[backupInterval.interval] : ''}
+
+ + {backupInterval?.interval === IntervalType.WEEKLY && ( +
+
Backup weekday
+
+ {displayedWeekday + ? weekdayLabels[displayedWeekday as keyof typeof weekdayLabels] + : ''} +
+
+ )} + + {backupInterval?.interval === IntervalType.MONTHLY && ( +
+
Backup day of month
+
{displayedDayOfMonth || ''}
+
+ )} + + {backupInterval?.interval !== IntervalType.HOURLY && ( +
+
Backup time of day
+
{formattedTime}
+
+ )} + +
+
Store period
+
{backupConfig.storePeriod ? periodLabels[backupConfig.storePeriod] : ''}
+
+ +
+
Storage
+
+
{backupConfig.storage?.name || ''}
+ {backupConfig.storage?.type && ( + storageIcon + )} +
+
+ +
+
Notifications
+
+ {backupConfig.sendNotificationsOn.length > 0 + ? backupConfig.sendNotificationsOn + .map((type) => notificationLabels[type]) + .join(', ') + : 'None'} +
+
+ + ) : ( +
+ )} +
+ ); +}; diff --git a/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx b/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx index 1448264..b6b989f 100644 --- a/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx +++ b/frontend/src/features/databases/ui/CreateDatabaseComponent.tsx @@ -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(); const [database, setDatabase] = useState({ 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 ( - 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!); }} /> ); diff --git a/frontend/src/features/databases/ui/DatabaseCardComponent.tsx b/frontend/src/features/databases/ui/DatabaseCardComponent.tsx index 148e55d..b04ca8c 100644 --- a/frontend/src/features/databases/ui/DatabaseCardComponent.tsx +++ b/frontend/src/features/databases/ui/DatabaseCardComponent.tsx @@ -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 = ({ databaseIcon
-
-
Store to: {database.storage?.name}
- - databaseIcon -
- {database.lastBackupTime && (
Last backup diff --git a/frontend/src/features/databases/ui/DatabaseComponent.tsx b/frontend/src/features/databases/ui/DatabaseComponent.tsx index 4018200..0c5d89a 100644 --- a/frontend/src/features/databases/ui/DatabaseComponent.tsx +++ b/frontend/src/features/databases/ui/DatabaseComponent.tsx @@ -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(); 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 = ({ )}
-
-
-
Backup settings
- - {!isEditBaseSettings ? ( -
startEdit('settings')} - > - -
- ) : ( -
- )} -
- -
- {isEditBaseSettings ? ( - { - setIsEditBaseSettings(false); - loadSettings(); - }} - isSaveToApi={true} - onSaved={onDatabaseChanged} - /> - ) : ( - - )} -
-
-
Database settings
@@ -292,17 +253,15 @@ export const DatabaseComponent = ({ )}
-
-
-
Storage settings
+
Backup config
- {!isEditStorageSettings ? ( + {!isEditBackupConfig ? (
startEdit('storage')} + onClick={() => startEdit('backup-config')} >
@@ -313,26 +272,58 @@ export const DatabaseComponent = ({
- {isEditStorageSettings ? ( - {}} onCancel={() => { - setIsEditStorageSettings(false); + setIsEditBackupConfig(false); loadSettings(); }} isSaveToApi={true} - onSaved={onDatabaseChanged} + onSaved={() => onDatabaseChanged(database)} + isShowBackButton={false} + onBack={() => {}} /> ) : ( - + )}
+
+ +
+
+
+
Healthcheck settings
+ + {!isEditHealthcheckSettings ? ( +
startEdit('healthcheck')} + > + +
+ ) : ( +
+ )} +
+ +
+ {isEditHealthcheckSettings ? ( + { + setIsEditHealthcheckSettings(false); + loadSettings(); + }} + /> + ) : ( + + )} +
+
@@ -373,39 +364,6 @@ export const DatabaseComponent = ({
-
-
-
-
Healthcheck settings
- - {!isEditHealthcheckSettings ? ( -
startEdit('healthcheck')} - > - -
- ) : ( -
- )} -
- -
- {isEditHealthcheckSettings ? ( - { - setIsEditHealthcheckSettings(false); - loadSettings(); - }} - /> - ) : ( - - )} -
-
-
- {!isEditDatabaseSpecificDataSettings && (
); }; diff --git a/frontend/src/features/databases/ui/edit/EditDatabaseBaseInfoComponent.tsx b/frontend/src/features/databases/ui/edit/EditDatabaseBaseInfoComponent.tsx index efd23a2..d3aa33d 100644 --- a/frontend/src/features/databases/ui/edit/EditDatabaseBaseInfoComponent.tsx +++ b/frontend/src/features/databases/ui/edit/EditDatabaseBaseInfoComponent.tsx @@ -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) => { setEditingDatabase((prev) => (prev ? { ...prev, ...patch } : prev)); setIsUnsaved(true); }; - const saveInterval = (patch: Partial) => { - 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 (
@@ -142,119 +74,13 @@ export const EditDatabaseBaseInfoComponent = ({
)} -
-
Backup interval
- { - if (!localWeekday) return; - const ref = localTime ?? dayjs(); - saveInterval({ weekday: getUtcWeekday(localWeekday, ref) }); - }} - size="small" - className="max-w-[200px] grow" - options={weekdayOptions} - /> -
- )} - - {backupInterval?.interval === IntervalType.MONTHLY && ( -
-
Backup day of month
- { - if (!localDom) return; - const ref = localTime ?? dayjs(); - saveInterval({ dayOfMonth: getUtcDayOfMonth(localDom, ref) }); - }} - size="small" - className="max-w-[200px] grow" - /> -
- )} - - {backupInterval?.interval !== IntervalType.HOURLY && ( -
-
Backup time of day
- { - if (!t) return; - const patch: Partial = { 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); - }} - /> -
- )} - -
-
Store period
- { - 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, - }, - ]} - /> -
-
Notifiers
diff --git a/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx b/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx index e1639ea..3bbc42f 100644 --- a/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx +++ b/frontend/src/features/databases/ui/edit/EditDatabaseSpecificDataComponent.tsx @@ -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(); 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} />
)} @@ -283,33 +287,6 @@ export const EditDatabaseSpecificDataComponent = ({ size="small" />
- -
-
CPU count
- { - 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} - /> - - - -
)} diff --git a/frontend/src/features/databases/ui/edit/EditDatabaseStorageComponent.tsx b/frontend/src/features/databases/ui/edit/EditDatabaseStorageComponent.tsx deleted file mode 100644 index 79b3bf1..0000000 --- a/frontend/src/features/databases/ui/edit/EditDatabaseStorageComponent.tsx +++ /dev/null @@ -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(); - const [isUnsaved, setIsUnsaved] = useState(false); - const [isSaving, setIsSaving] = useState(false); - - const [storages, setStorages] = useState([]); - 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 ( -
- -
- ); - - return ( -
-
- Storage - is a place where backups will be stored (local disk, S3, Google Drive, etc.) -
- -
-
Storages
- -