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