FEATURE (backups): Move backups to separate backup config and make feature optional

This commit is contained in:
Rostislav Dugin
2025-07-08 22:04:20 +03:00
parent 7c9faf7d52
commit aa6b495cff
76 changed files with 2254 additions and 1255 deletions

View File

@@ -2,7 +2,7 @@
<img src="assets/logo.svg" alt="Postgresus Logo" width="250"/>
<h3>PostgreSQL monitoring and backup</h3>
<p>Free, open source and self-hosted solution for automated PostgreSQL monitoring and backups with multiple storage options and notifications</p>
<p>Free, open source and self-hosted solution for automated PostgreSQL monitoring and backups. With multiple storage options and notifications</p>
<p>
<a href="#-features">Features</a> •

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -15,7 +15,7 @@ GOOSE_MIGRATION_DIR=./migrations
# to get Google Drive env variables: add storage in UI and copy data from added storage here
TEST_GOOGLE_DRIVE_CLIENT_ID=
TEST_GOOGLE_DRIVE_CLIENT_SECRET=
TEST_GOOGLE_DRIVE_TOKEN_JSON=
TEST_GOOGLE_DRIVE_TOKEN_JSON="{\"access_token\":\"ya29..."
# testing DBs
TEST_POSTGRES_13_PORT=5001
TEST_POSTGRES_14_PORT=5002

3
backend/.gitignore vendored
View File

@@ -10,4 +10,5 @@ swagger/docs.go
swagger/swagger.json
swagger/swagger.yaml
postgresus-backend.exe
ui/build/*
ui/build/*
pgdata-for-restore/

View File

@@ -14,7 +14,8 @@ import (
"postgresus-backend/internal/config"
"postgresus-backend/internal/downdetect"
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/backups/backups"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/disk"
healthcheck_attempt "postgresus-backend/internal/features/healthcheck/attempt"
@@ -135,6 +136,7 @@ func setUpRoutes(r *gin.Engine) {
healthcheckConfigController := healthcheck_config.GetHealthcheckConfigController()
healthcheckAttemptController := healthcheck_attempt.GetHealthcheckAttemptController()
diskController := disk.GetDiskController()
backupConfigController := backups_config.GetBackupConfigController()
downdetectContoller.RegisterRoutes(v1)
userController.RegisterRoutes(v1)
@@ -147,10 +149,13 @@ func setUpRoutes(r *gin.Engine) {
diskController.RegisterRoutes(v1)
healthcheckConfigController.RegisterRoutes(v1)
healthcheckAttemptController.RegisterRoutes(v1)
backupConfigController.RegisterRoutes(v1)
}
func setUpDependencies() {
backups.SetupDependencies()
backups.SetupDependencies()
restores.SetupDependencies()
healthcheck_config.SetupDependencies()
}

View File

@@ -3,16 +3,19 @@ package backups
import (
"log/slog"
"postgresus-backend/internal/config"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/util/period"
"time"
)
type BackupBackgroundService struct {
backupService *BackupService
backupRepository *BackupRepository
databaseService *databases.DatabaseService
storageService *storages.StorageService
backupService *BackupService
backupRepository *BackupRepository
databaseService *databases.DatabaseService
storageService *storages.StorageService
backupConfigService *backups_config.BackupConfigService
lastBackupTime time.Time
logger *slog.Logger
@@ -60,15 +63,21 @@ func (s *BackupBackgroundService) failBackupsInProgress() error {
}
for _, backup := range backupsInProgress {
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(backup.DatabaseID)
if err != nil {
s.logger.Error("Failed to get backup config by database ID", "error", err)
continue
}
failMessage := "Backup failed due to application restart"
backup.FailMessage = &failMessage
backup.Status = BackupStatusFailed
backup.BackupSizeMb = 0
s.backupService.SendBackupNotification(
backup.Database,
backupConfig,
backup,
databases.NotificationBackupFailed,
backups_config.NotificationBackupFailed,
&failMessage,
)
@@ -87,9 +96,15 @@ func (s *BackupBackgroundService) cleanOldBackups() error {
}
for _, database := range allDatabases {
backupStorePeriod := database.StorePeriod
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(database.ID)
if err != nil {
s.logger.Error("Failed to get backup config by database ID", "error", err)
continue
}
if backupStorePeriod == databases.PeriodForever {
backupStorePeriod := backupConfig.StorePeriod
if backupStorePeriod == period.PeriodForever {
continue
}
@@ -148,7 +163,13 @@ func (s *BackupBackgroundService) runPendingBackups() error {
}
for _, database := range allDatabases {
if database.BackupInterval == nil {
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(database.ID)
if err != nil {
s.logger.Error("Failed to get backup config by database ID", "error", err)
continue
}
if backupConfig.BackupInterval == nil {
continue
}
@@ -169,13 +190,13 @@ func (s *BackupBackgroundService) runPendingBackups() error {
lastBackupTime = &lastBackup.CreatedAt
}
if database.BackupInterval.ShouldTriggerBackup(time.Now().UTC(), lastBackupTime) {
if backupConfig.BackupInterval.ShouldTriggerBackup(time.Now().UTC(), lastBackupTime) {
s.logger.Info(
"Triggering scheduled backup",
"databaseId",
database.ID,
"intervalType",
database.BackupInterval.Interval,
backupConfig.BackupInterval.Interval,
)
go s.backupService.MakeBackup(database.ID)

View File

@@ -1,7 +1,8 @@
package backups
import (
"postgresus-backend/internal/features/backups/usecases"
"postgresus-backend/internal/features/backups/backups/usecases"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
@@ -17,8 +18,10 @@ var backupService = &BackupService{
backupRepository,
notifiers.GetNotifierService(),
notifiers.GetNotifierService(),
backups_config.GetBackupConfigService(),
usecases.GetCreateBackupUsecase(),
logger.GetLogger(),
[]BackupRemoveListener{},
}
var backupBackgroundService = &BackupBackgroundService{
@@ -26,6 +29,7 @@ var backupBackgroundService = &BackupBackgroundService{
backupRepository,
databases.GetDatabaseService(),
storages.GetStorageService(),
backups_config.GetBackupConfigService(),
time.Now().UTC(),
logger.GetLogger(),
}
@@ -36,9 +40,11 @@ var backupController = &BackupController{
}
func SetupDependencies() {
databases.
GetDatabaseService().
backups_config.
GetBackupConfigService().
SetDatabaseStorageChangeListener(backupService)
databases.GetDatabaseService().AddDbRemoveListener(backupService)
}
func GetBackupService() *BackupService {

View File

@@ -6,5 +6,4 @@ const (
BackupStatusInProgress BackupStatus = "IN_PROGRESS"
BackupStatusCompleted BackupStatus = "COMPLETED"
BackupStatusFailed BackupStatus = "FAILED"
BackupStatusDeleted BackupStatus = "DELETED"
)

View File

@@ -1,6 +1,7 @@
package backups
import (
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
@@ -19,6 +20,7 @@ type NotificationSender interface {
type CreateBackupUsecase interface {
Execute(
backupID uuid.UUID,
backupConfig *backups_config.BackupConfig,
database *databases.Database,
storage *storages.Storage,
backupProgressListener func(
@@ -26,3 +28,7 @@ type CreateBackupUsecase interface {
),
) error
}
type BackupRemoveListener interface {
OnBeforeBackupRemove(backup *Backup) error
}

View File

@@ -43,6 +43,22 @@ func (r *BackupRepository) FindByDatabaseID(databaseID uuid.UUID) ([]*Backup, er
return backups, nil
}
func (r *BackupRepository) FindByStorageID(storageID uuid.UUID) ([]*Backup, error) {
var backups []*Backup
if err := storage.
GetDb().
Preload("Database").
Preload("Storage").
Where("storage_id = ?", storageID).
Order("created_at DESC").
Find(&backups).Error; err != nil {
return nil, err
}
return backups, nil
}
func (r *BackupRepository) FindLastByDatabaseID(databaseID uuid.UUID) (*Backup, error) {
var backup Backup
@@ -113,6 +129,25 @@ func (r *BackupRepository) FindByStorageIdAndStatus(
return backups, nil
}
func (r *BackupRepository) FindByDatabaseIdAndStatus(
databaseID uuid.UUID,
status BackupStatus,
) ([]*Backup, error) {
var backups []*Backup
if err := storage.
GetDb().
Preload("Database").
Preload("Storage").
Where("database_id = ? AND status = ?", databaseID, status).
Order("created_at DESC").
Find(&backups).Error; err != nil {
return nil, err
}
return backups, nil
}
func (r *BackupRepository) DeleteByID(id uuid.UUID) error {
return storage.GetDb().Delete(&Backup{}, "id = ?", id).Error
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"log/slog"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
@@ -15,60 +16,42 @@ import (
)
type BackupService struct {
databaseService *databases.DatabaseService
storageService *storages.StorageService
backupRepository *BackupRepository
notifierService *notifiers.NotifierService
notificationSender NotificationSender
databaseService *databases.DatabaseService
storageService *storages.StorageService
backupRepository *BackupRepository
notifierService *notifiers.NotifierService
notificationSender NotificationSender
backupConfigService *backups_config.BackupConfigService
createBackupUseCase CreateBackupUsecase
logger *slog.Logger
backupRemoveListeners []BackupRemoveListener
}
func (s *BackupService) OnBeforeDbStorageChange(
func (s *BackupService) AddBackupRemoveListener(listener BackupRemoveListener) {
s.backupRemoveListeners = append(s.backupRemoveListeners, listener)
}
func (s *BackupService) OnBeforeBackupsStorageChange(
databaseID uuid.UUID,
storageID uuid.UUID,
) error {
// validate no backups in progress
backups, err := s.backupRepository.FindByStorageIdAndStatus(
storageID,
BackupStatusInProgress,
)
err := s.deleteDbBackups(databaseID)
if err != nil {
return err
}
if len(backups) > 0 {
return errors.New("backup is in progress, storage cannot")
}
return nil
}
backupsWithStorage, err := s.backupRepository.FindByStorageIdAndStatus(
storageID,
BackupStatusCompleted,
)
func (s *BackupService) OnBeforeDatabaseRemove(databaseID uuid.UUID) error {
err := s.deleteDbBackups(databaseID)
if err != nil {
return err
}
if len(backupsWithStorage) > 0 {
for _, backup := range backupsWithStorage {
if err := backup.Storage.DeleteFile(backup.ID); err != nil {
// most likely we cannot do nothing with this,
// so we just remove the backup model
s.logger.Error("Failed to delete backup file", "error", err)
}
if err := s.backupRepository.DeleteByID(backup.ID); err != nil {
return err
}
}
// we repeat remove for the case if backup
// started until we removed all previous backups
return s.OnBeforeDbStorageChange(databaseID, storageID)
}
return nil
}
@@ -128,10 +111,7 @@ func (s *BackupService) DeleteBackup(
return errors.New("backup is in progress")
}
backup.DeleteBackupFromStorage(s.logger)
backup.Status = BackupStatusDeleted
return s.backupRepository.Save(backup)
return s.deleteBackup(backup)
}
func (s *BackupService) MakeBackup(databaseID uuid.UUID) {
@@ -152,7 +132,23 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID) {
return
}
storage, err := s.storageService.GetStorageByID(database.StorageID)
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(databaseID)
if err != nil {
s.logger.Error("Failed to get backup config by database ID", "error", err)
return
}
if !backupConfig.IsBackupsEnabled {
s.logger.Info("Backups are not enabled for this database")
return
}
if backupConfig.StorageID == nil {
s.logger.Error("Backup config storage ID is not defined")
return
}
storage, err := s.storageService.GetStorageByID(*backupConfig.StorageID)
if err != nil {
s.logger.Error("Failed to get storage by ID", "error", err)
return
@@ -192,6 +188,7 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID) {
err = s.createBackupUseCase.Execute(
backup.ID,
backupConfig,
database,
storage,
backupProgressListener,
@@ -218,9 +215,9 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID) {
}
s.SendBackupNotification(
database,
backupConfig,
backup,
databases.NotificationBackupFailed,
backups_config.NotificationBackupFailed,
&errMsg,
)
@@ -248,27 +245,27 @@ func (s *BackupService) MakeBackup(databaseID uuid.UUID) {
}
s.SendBackupNotification(
database,
backupConfig,
backup,
databases.NotificationBackupSuccess,
backups_config.NotificationBackupSuccess,
nil,
)
}
func (s *BackupService) SendBackupNotification(
db *databases.Database,
backupConfig *backups_config.BackupConfig,
backup *Backup,
notificationType databases.BackupNotificationType,
notificationType backups_config.BackupNotificationType,
errorMessage *string,
) {
database, err := s.databaseService.GetDatabaseByID(db.ID)
database, err := s.databaseService.GetDatabaseByID(backupConfig.DatabaseID)
if err != nil {
return
}
for _, notifier := range database.Notifiers {
if !slices.Contains(
database.SendNotificationsOn,
backupConfig.SendNotificationsOn,
notificationType,
) {
continue
@@ -276,9 +273,9 @@ func (s *BackupService) SendBackupNotification(
title := ""
switch notificationType {
case databases.NotificationBackupFailed:
case backups_config.NotificationBackupFailed:
title = fmt.Sprintf("❌ Backup failed for database \"%s\"", database.Name)
case databases.NotificationBackupSuccess:
case backups_config.NotificationBackupSuccess:
title = fmt.Sprintf("✅ Backup completed for database \"%s\"", database.Name)
}
@@ -319,3 +316,45 @@ func (s *BackupService) SendBackupNotification(
func (s *BackupService) GetBackup(backupID uuid.UUID) (*Backup, error) {
return s.backupRepository.FindByID(backupID)
}
func (s *BackupService) deleteBackup(backup *Backup) error {
for _, listener := range s.backupRemoveListeners {
if err := listener.OnBeforeBackupRemove(backup); err != nil {
return err
}
}
backup.DeleteBackupFromStorage(s.logger)
return s.backupRepository.DeleteByID(backup.ID)
}
func (s *BackupService) deleteDbBackups(databaseID uuid.UUID) error {
dbBackupsInProgress, err := s.backupRepository.FindByDatabaseIdAndStatus(
databaseID,
BackupStatusInProgress,
)
if err != nil {
return err
}
if len(dbBackupsInProgress) > 0 {
return errors.New("backup is in progress, storage cannot be removed")
}
dbBackups, err := s.backupRepository.FindByDatabaseID(
databaseID,
)
if err != nil {
return err
}
for _, dbBackup := range dbBackups {
err := s.deleteBackup(dbBackup)
if err != nil {
return err
}
}
return nil
}

View File

@@ -2,6 +2,7 @@ package backups
import (
"errors"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
@@ -20,6 +21,7 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
storage := storages.CreateTestStorage(user.UserID)
notifier := notifiers.CreateTestNotifier(user.UserID)
database := databases.CreateTestDatabase(user.UserID, storage, notifier)
backups_config.EnableBackupsForTestDatabase(database.ID, storage)
defer storages.RemoveTestStorage(storage.ID)
defer notifiers.RemoveTestNotifier(notifier)
@@ -33,8 +35,10 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
backupRepository,
notifiers.GetNotifierService(),
mockNotificationSender,
backups_config.GetBackupConfigService(),
&CreateFailedBackupUsecase{},
logger.GetLogger(),
[]BackupRemoveListener{},
}
// Set up expectations
@@ -74,8 +78,10 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
backupRepository,
notifiers.GetNotifierService(),
mockNotificationSender,
backups_config.GetBackupConfigService(),
&CreateSuccessBackupUsecase{},
logger.GetLogger(),
[]BackupRemoveListener{},
}
backupService.MakeBackup(database.ID)
@@ -92,8 +98,10 @@ func Test_BackupExecuted_NotificationSent(t *testing.T) {
backupRepository,
notifiers.GetNotifierService(),
mockNotificationSender,
backups_config.GetBackupConfigService(),
&CreateSuccessBackupUsecase{},
logger.GetLogger(),
[]BackupRemoveListener{},
}
// capture arguments
@@ -130,6 +138,7 @@ type CreateFailedBackupUsecase struct {
func (uc *CreateFailedBackupUsecase) Execute(
backupID uuid.UUID,
backupConfig *backups_config.BackupConfig,
database *databases.Database,
storage *storages.Storage,
backupProgressListener func(
@@ -145,6 +154,7 @@ type CreateSuccessBackupUsecase struct {
func (uc *CreateSuccessBackupUsecase) Execute(
backupID uuid.UUID,
backupConfig *backups_config.BackupConfig,
database *databases.Database,
storage *storages.Storage,
backupProgressListener func(

View File

@@ -2,7 +2,8 @@ package usecases
import (
"errors"
usecases_postgresql "postgresus-backend/internal/features/backups/usecases/postgresql"
usecases_postgresql "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/storages"
@@ -16,6 +17,7 @@ type CreateBackupUsecase struct {
// Execute creates a backup of the database and returns the backup size in MB
func (uc *CreateBackupUsecase) Execute(
backupID uuid.UUID,
backupConfig *backups_config.BackupConfig,
database *databases.Database,
storage *storages.Storage,
backupProgressListener func(
@@ -25,6 +27,7 @@ func (uc *CreateBackupUsecase) Execute(
if database.Type == databases.DatabaseTypePostgres {
return uc.CreatePostgresqlBackupUsecase.Execute(
backupID,
backupConfig,
database,
storage,
backupProgressListener,

View File

@@ -1,7 +1,7 @@
package usecases
import (
usecases_postgresql "postgresus-backend/internal/features/backups/usecases/postgresql"
usecases_postgresql "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
)
var createBackupUsecase = &CreateBackupUsecase{

View File

@@ -14,6 +14,7 @@ import (
"time"
"postgresus-backend/internal/config"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/storages"
@@ -29,6 +30,7 @@ type CreatePostgresqlBackupUsecase struct {
// Execute creates a backup of the database
func (uc *CreatePostgresqlBackupUsecase) Execute(
backupID uuid.UUID,
backupConfig *backups_config.BackupConfig,
db *databases.Database,
storage *storages.Storage,
backupProgressListener func(
@@ -43,6 +45,10 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
storage.ID,
)
if !backupConfig.IsBackupsEnabled {
return fmt.Errorf("backups are not enabled for this database: \"%s\"", db.Name)
}
pg := db.Postgresql
if pg == nil {
@@ -66,6 +72,7 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
return uc.streamToStorage(
backupID,
backupConfig,
tools.GetPostgresqlExecutable(
pg.Version,
"pg_dump",
@@ -83,6 +90,7 @@ func (uc *CreatePostgresqlBackupUsecase) Execute(
// streamToStorage streams pg_dump output directly to storage
func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
backupID uuid.UUID,
backupConfig *backups_config.BackupConfig,
pgBin string,
args []string,
password string,
@@ -156,7 +164,7 @@ func (uc *CreatePostgresqlBackupUsecase) streamToStorage(
"passwordEmpty", password == "",
"pgBin", pgBin,
"usingPgpassFile", true,
"parallelJobs", db.Postgresql.CpuCount,
"parallelJobs", backupConfig.CpuCount,
)
// Add PostgreSQL-specific environment variables

View 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})
}

View 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
}

View File

@@ -0,0 +1,8 @@
package backups_config
type BackupNotificationType string
const (
NotificationBackupFailed BackupNotificationType = "BACKUP_FAILED"
NotificationBackupSuccess BackupNotificationType = "BACKUP_SUCCESS"
)

View File

@@ -0,0 +1,7 @@
package backups_config
import "github.com/google/uuid"
type BackupConfigStorageChangeListener interface {
OnBeforeBackupsStorageChange(dbID uuid.UUID, storageID uuid.UUID) error
}

View 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
}

View 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
}

View 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
}

View 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
}

View File

@@ -22,7 +22,7 @@ func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("/databases/:id/test-connection", c.TestDatabaseConnection)
router.POST("/databases/test-connection-direct", c.TestDatabaseConnectionDirect)
router.GET("/databases/notifier/:id/is-using", c.IsNotifierUsing)
router.GET("/databases/storage/:id/is-using", c.IsStorageUsing)
}
// CreateDatabase
@@ -56,12 +56,13 @@ func (c *DatabaseController) CreateDatabase(ctx *gin.Context) {
return
}
if err := c.databaseService.CreateDatabase(user, &request); err != nil {
database, err := c.databaseService.CreateDatabase(user, &request)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusCreated, request)
ctx.JSON(http.StatusCreated, database)
}
// UpdateDatabase
@@ -310,52 +311,13 @@ func (c *DatabaseController) IsNotifierUsing(ctx *gin.Context) {
return
}
_, err = c.userService.GetUserFromToken(authorizationHeader)
user, err := c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
isUsing, err := c.databaseService.IsNotifierUsing(id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing})
}
// IsStorageUsing
// @Summary Check if storage is being used
// @Description Check if a storage is currently being used by any database
// @Tags databases
// @Produce json
// @Param id path string true "Storage ID"
// @Success 200 {object} map[string]bool
// @Failure 400
// @Failure 401
// @Failure 500
// @Router /databases/storage/{id}/is-using [get]
func (c *DatabaseController) IsStorageUsing(ctx *gin.Context) {
id, err := uuid.Parse(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid storage ID"})
return
}
authorizationHeader := ctx.GetHeader("Authorization")
if authorizationHeader == "" {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
return
}
_, err = c.userService.GetUserFromToken(authorizationHeader)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
isUsing, err := c.databaseService.IsStorageUsing(id)
isUsing, err := c.databaseService.IsNotifierUsing(user, id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return

View File

@@ -27,8 +27,6 @@ type PostgresqlDatabase struct {
Password string `json:"password" gorm:"type:text;not null"`
Database *string `json:"database" gorm:"type:text"`
IsHttps bool `json:"isHttps" gorm:"type:boolean;default:false"`
CpuCount int `json:"cpuCount" gorm:"type:int;not null"`
}
func (p *PostgresqlDatabase) TableName() string {

View File

@@ -12,8 +12,8 @@ var databaseService = &DatabaseService{
databaseRepository,
notifiers.GetNotifierService(),
logger.GetLogger(),
nil,
nil,
[]DatabaseCreationListener{},
[]DatabaseRemoveListener{},
}
var databaseController = &DatabaseController{

View File

@@ -1,66 +1,11 @@
package databases
import "time"
type DatabaseType string
const (
DatabaseTypePostgres DatabaseType = "POSTGRES"
)
type Period string
const (
PeriodDay Period = "DAY"
PeriodWeek Period = "WEEK"
PeriodMonth Period = "MONTH"
Period3Month Period = "3_MONTH"
Period6Month Period = "6_MONTH"
PeriodYear Period = "YEAR"
Period2Years Period = "2_YEARS"
Period3Years Period = "3_YEARS"
Period4Years Period = "4_YEARS"
Period5Years Period = "5_YEARS"
PeriodForever Period = "FOREVER"
)
// ToDuration converts Period to time.Duration
func (p Period) ToDuration() time.Duration {
switch p {
case PeriodDay:
return 24 * time.Hour
case PeriodWeek:
return 7 * 24 * time.Hour
case PeriodMonth:
return 30 * 24 * time.Hour
case Period3Month:
return 90 * 24 * time.Hour
case Period6Month:
return 180 * 24 * time.Hour
case PeriodYear:
return 365 * 24 * time.Hour
case Period2Years:
return 2 * 365 * 24 * time.Hour
case Period3Years:
return 3 * 365 * 24 * time.Hour
case Period4Years:
return 4 * 365 * 24 * time.Hour
case Period5Years:
return 5 * 365 * 24 * time.Hour
case PeriodForever:
return 0
default:
panic("unknown period: " + string(p))
}
}
type BackupNotificationType string
const (
NotificationBackupFailed BackupNotificationType = "BACKUP_FAILED"
NotificationBackupSuccess BackupNotificationType = "BACKUP_SUCCESS"
)
type HealthStatus string
const (

View File

@@ -14,10 +14,10 @@ type DatabaseConnector interface {
TestConnection(logger *slog.Logger) error
}
type DatabaseStorageChangeListener interface {
OnBeforeDbStorageChange(dbID uuid.UUID, storageID uuid.UUID) error
}
type DatabaseCreationListener interface {
OnDatabaseCreated(databaseID uuid.UUID)
}
type DatabaseRemoveListener interface {
OnBeforeDatabaseRemove(databaseID uuid.UUID) error
}

View File

@@ -4,34 +4,21 @@ import (
"errors"
"log/slog"
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/intervals"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Database struct {
ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"`
UserID uuid.UUID `json:"userId" gorm:"column:user_id;type:uuid;not null"`
Name string `json:"name" gorm:"column:name;type:text;not null"`
Type DatabaseType `json:"type" gorm:"column:type;type:text;not null"`
StorePeriod Period `json:"storePeriod" gorm:"column:store_period;type:text;not null"`
BackupIntervalID uuid.UUID `json:"backupIntervalId" gorm:"column:backup_interval_id;type:uuid;not null"`
BackupInterval *intervals.Interval `json:"backupInterval,omitempty" gorm:"foreignKey:BackupIntervalID"`
ID uuid.UUID `json:"id" gorm:"column:id;primaryKey;type:uuid;default:gen_random_uuid()"`
UserID uuid.UUID `json:"userId" gorm:"column:user_id;type:uuid;not null"`
Name string `json:"name" gorm:"column:name;type:text;not null"`
Type DatabaseType `json:"type" gorm:"column:type;type:text;not null"`
Postgresql *postgresql.PostgresqlDatabase `json:"postgresql,omitempty" gorm:"foreignKey:DatabaseID"`
Storage storages.Storage `json:"storage" gorm:"foreignKey:StorageID"`
StorageID uuid.UUID `json:"storageId" gorm:"column:storage_id;type:uuid;not null"`
Notifiers []notifiers.Notifier `json:"notifiers" gorm:"many2many:database_notifiers;"`
SendNotificationsOn []BackupNotificationType `json:"sendNotificationsOn" gorm:"-"`
SendNotificationsOnString string `json:"-" gorm:"column:send_notifications_on;type:text;not null"`
Notifiers []notifiers.Notifier `json:"notifiers" gorm:"many2many:database_notifiers;"`
// these fields are not reliable, but
// they are used for pretty UI
@@ -41,56 +28,11 @@ type Database struct {
HealthStatus *HealthStatus `json:"healthStatus" gorm:"column:health_status;type:text;not null"`
}
func (d *Database) BeforeSave(tx *gorm.DB) error {
// Convert SendNotificationsOn array to string
if len(d.SendNotificationsOn) > 0 {
notificationTypes := make([]string, len(d.SendNotificationsOn))
for i, notificationType := range d.SendNotificationsOn {
notificationTypes[i] = string(notificationType)
}
d.SendNotificationsOnString = strings.Join(notificationTypes, ",")
} else {
d.SendNotificationsOnString = ""
}
return nil
}
func (d *Database) AfterFind(tx *gorm.DB) error {
// Convert SendNotificationsOnString to array
if d.SendNotificationsOnString != "" {
notificationTypes := strings.Split(d.SendNotificationsOnString, ",")
d.SendNotificationsOn = make([]BackupNotificationType, len(notificationTypes))
for i, notificationType := range notificationTypes {
d.SendNotificationsOn[i] = BackupNotificationType(notificationType)
}
} else {
d.SendNotificationsOn = []BackupNotificationType{}
}
return nil
}
func (d *Database) Validate() error {
if d.Name == "" {
return errors.New("name is required")
}
// Backup interval is required either as ID or as object
if d.BackupIntervalID == uuid.Nil && d.BackupInterval == nil {
return errors.New("backup interval is required")
}
if d.StorePeriod == "" {
return errors.New("store period is required")
}
if d.Postgresql.CpuCount == 0 {
return errors.New("cpu count is required")
}
switch d.Type {
case DatabaseTypePostgres:
return d.Postgresql.Validate()

View File

@@ -18,25 +18,7 @@ func (r *DatabaseRepository) Save(database *Database) (*Database, error) {
database.ID = uuid.New()
}
database.StorageID = database.Storage.ID
err := db.Transaction(func(tx *gorm.DB) error {
if database.BackupInterval != nil {
if database.BackupInterval.ID == uuid.Nil {
if err := tx.Create(database.BackupInterval).Error; err != nil {
return err
}
database.BackupIntervalID = database.BackupInterval.ID
} else {
if err := tx.Save(database.BackupInterval).Error; err != nil {
return err
}
database.BackupIntervalID = database.BackupInterval.ID
}
}
switch database.Type {
case DatabaseTypePostgres:
if database.Postgresql != nil {
@@ -46,13 +28,13 @@ func (r *DatabaseRepository) Save(database *Database) (*Database, error) {
if isNew {
if err := tx.Create(database).
Omit("Postgresql", "Storage", "Notifiers", "BackupInterval").
Omit("Postgresql", "Notifiers").
Error; err != nil {
return err
}
} else {
if err := tx.Save(database).
Omit("Postgresql", "Storage", "Notifiers", "BackupInterval").
Omit("Postgresql", "Notifiers").
Error; err != nil {
return err
}
@@ -76,7 +58,10 @@ func (r *DatabaseRepository) Save(database *Database) (*Database, error) {
}
}
if err := tx.Model(database).Association("Notifiers").Replace(database.Notifiers); err != nil {
if err := tx.
Model(database).
Association("Notifiers").
Replace(database.Notifiers); err != nil {
return err
}
@@ -95,9 +80,7 @@ func (r *DatabaseRepository) FindByID(id uuid.UUID) (*Database, error) {
if err := storage.
GetDb().
Preload("BackupInterval").
Preload("Postgresql").
Preload("Storage").
Preload("Notifiers").
Where("id = ?", id).
First(&database).Error; err != nil {
@@ -112,9 +95,7 @@ func (r *DatabaseRepository) FindByUserID(userID uuid.UUID) ([]*Database, error)
if err := storage.
GetDb().
Preload("BackupInterval").
Preload("Postgresql").
Preload("Storage").
Preload("Notifiers").
Where("user_id = ?", userID).
Order("CASE WHEN health_status = 'UNAVAILABLE' THEN 1 WHEN health_status = 'AVAILABLE' THEN 2 WHEN health_status IS NULL THEN 3 ELSE 4 END, name ASC").
@@ -140,7 +121,9 @@ func (r *DatabaseRepository) Delete(id uuid.UUID) error {
switch database.Type {
case DatabaseTypePostgres:
if err := tx.Where("database_id = ?", id).Delete(&postgresql.PostgresqlDatabase{}).Error; err != nil {
if err := tx.
Where("database_id = ?", id).
Delete(&postgresql.PostgresqlDatabase{}).Error; err != nil {
return err
}
}
@@ -167,28 +150,12 @@ func (r *DatabaseRepository) IsNotifierUsing(notifierID uuid.UUID) (bool, error)
return count > 0, nil
}
func (r *DatabaseRepository) IsStorageUsing(storageID uuid.UUID) (bool, error) {
var count int64
if err := storage.
GetDb().
Table("databases").
Where("storage_id = ?", storageID).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *DatabaseRepository) GetAllDatabases() ([]*Database, error) {
var databases []*Database
if err := storage.
GetDb().
Preload("BackupInterval").
Preload("Postgresql").
Preload("Storage").
Preload("Notifiers").
Find(&databases).Error; err != nil {
return nil, err

View File

@@ -15,14 +15,8 @@ type DatabaseService struct {
notifierService *notifiers.NotifierService
logger *slog.Logger
dbStorageChangeListener DatabaseStorageChangeListener
dbCreationListener []DatabaseCreationListener
}
func (s *DatabaseService) SetDatabaseStorageChangeListener(
dbStorageChangeListener DatabaseStorageChangeListener,
) {
s.dbStorageChangeListener = dbStorageChangeListener
dbCreationListener []DatabaseCreationListener
dbRemoveListener []DatabaseRemoveListener
}
func (s *DatabaseService) AddDbCreationListener(
@@ -31,26 +25,32 @@ func (s *DatabaseService) AddDbCreationListener(
s.dbCreationListener = append(s.dbCreationListener, dbCreationListener)
}
func (s *DatabaseService) AddDbRemoveListener(
dbRemoveListener DatabaseRemoveListener,
) {
s.dbRemoveListener = append(s.dbRemoveListener, dbRemoveListener)
}
func (s *DatabaseService) CreateDatabase(
user *users_models.User,
database *Database,
) error {
) (*Database, error) {
database.UserID = user.ID
if err := database.Validate(); err != nil {
return err
return nil, err
}
database, err := s.dbRepository.Save(database)
if err != nil {
return err
return nil, err
}
for _, listener := range s.dbCreationListener {
listener.OnDatabaseCreated(database.ID)
}
return nil
return database, nil
}
func (s *DatabaseService) UpdateDatabase(
@@ -79,17 +79,6 @@ func (s *DatabaseService) UpdateDatabase(
return err
}
if existingDatabase.Storage.ID != database.Storage.ID {
err := s.dbStorageChangeListener.OnBeforeDbStorageChange(
existingDatabase.ID,
database.StorageID,
)
if err != nil {
return err
}
}
_, err = s.dbRepository.Save(database)
if err != nil {
return err
@@ -111,6 +100,12 @@ func (s *DatabaseService) DeleteDatabase(
return errors.New("you have not access to this database")
}
for _, listener := range s.dbRemoveListener {
if err := listener.OnBeforeDatabaseRemove(id); err != nil {
return err
}
}
return s.dbRepository.Delete(id)
}
@@ -136,12 +131,16 @@ func (s *DatabaseService) GetDatabasesByUser(
return s.dbRepository.FindByUserID(user.ID)
}
func (s *DatabaseService) IsNotifierUsing(notifierID uuid.UUID) (bool, error) {
return s.dbRepository.IsNotifierUsing(notifierID)
}
func (s *DatabaseService) IsNotifierUsing(
user *users_models.User,
notifierID uuid.UUID,
) (bool, error) {
_, err := s.notifierService.GetNotifier(user, notifierID)
if err != nil {
return false, err
}
func (s *DatabaseService) IsStorageUsing(storageID uuid.UUID) (bool, error) {
return s.dbRepository.IsStorageUsing(storageID)
return s.dbRepository.IsNotifierUsing(notifierID)
}
func (s *DatabaseService) TestDatabaseConnection(

View File

@@ -2,7 +2,6 @@ package databases
import (
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/intervals"
"postgresus-backend/internal/features/notifiers"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/util/tools"
@@ -15,18 +14,10 @@ func CreateTestDatabase(
storage *storages.Storage,
notifier *notifiers.Notifier,
) *Database {
timeOfDay := "16:00"
database := &Database{
UserID: userID,
Name: "test " + uuid.New().String(),
Type: DatabaseTypePostgres,
StorePeriod: PeriodDay,
BackupInterval: &intervals.Interval{
Interval: intervals.IntervalDaily,
TimeOfDay: &timeOfDay,
},
UserID: userID,
Name: "test " + uuid.New().String(),
Type: DatabaseTypePostgres,
Postgresql: &postgresql.PostgresqlDatabase{
Version: tools.PostgresqlVersion16,
@@ -36,16 +27,9 @@ func CreateTestDatabase(
Password: "postgres",
},
StorageID: storage.ID,
Storage: *storage,
Notifiers: []notifiers.Notifier{
*notifier,
},
SendNotificationsOn: []BackupNotificationType{
NotificationBackupFailed,
NotificationBackupSuccess,
},
}
database, err := databaseRepository.Save(database)

View File

@@ -83,7 +83,6 @@ func (uc *CheckPgHealthUseCase) updateDatabaseHealthStatusIfChanged(
heathcheckAttempt *HealthcheckAttempt,
) error {
if &heathcheckAttempt.Status == database.HealthStatus {
fmt.Println("Database health status is the same as the attempt status")
return nil
}
@@ -226,7 +225,7 @@ func (uc *CheckPgHealthUseCase) sendDbStatusNotification(
if newHealthStatus == databases.HealthStatusAvailable {
messageTitle = fmt.Sprintf("✅ [%s] DB is back online", database.Name)
messageBody = fmt.Sprintf("✅ [%s] DB is back online after being unavailable", database.Name)
messageBody = fmt.Sprintf("✅ [%s] DB is back online", database.Name)
} else {
messageTitle = fmt.Sprintf("❌ [%s] DB is unavailable", database.Name)
messageBody = fmt.Sprintf("❌ [%s] DB is currently unavailable", database.Name)

View File

@@ -85,8 +85,8 @@ func Test_CheckPgHealthUseCase(t *testing.T) {
t,
"SendNotification",
mock.Anything,
fmt.Sprintf("❌ DB [%s] is unavailable", database.Name),
fmt.Sprintf("❌ The [%s] database is currently unavailable", database.Name),
fmt.Sprintf("❌ [%s] DB is unavailable", database.Name),
fmt.Sprintf("❌ [%s] DB is currently unavailable", database.Name),
)
})
@@ -150,8 +150,8 @@ func Test_CheckPgHealthUseCase(t *testing.T) {
t,
"SendNotification",
mock.Anything,
fmt.Sprintf("❌ DB [%s] is unavailable", database.Name),
fmt.Sprintf("❌ The [%s] database is currently unavailable", database.Name),
fmt.Sprintf("❌ [%s] DB is unavailable", database.Name),
fmt.Sprintf("❌ [%s] DB is currently unavailable", database.Name),
)
},
)
@@ -230,7 +230,7 @@ func Test_CheckPgHealthUseCase(t *testing.T) {
"SendNotification",
mock.Anything,
fmt.Sprintf("❌ [%s] DB is unavailable", database.Name),
fmt.Sprintf("❌ [%s] Database is currently unavailable", database.Name),
fmt.Sprintf("❌ [%s] DB is currently unavailable", database.Name),
)
},
)
@@ -302,8 +302,8 @@ func Test_CheckPgHealthUseCase(t *testing.T) {
t,
"SendNotification",
mock.Anything,
fmt.Sprintf("✅ DB [%s] is back online", database.Name),
fmt.Sprintf("✅ The [%s] database is back online after being unavailable", database.Name),
fmt.Sprintf("✅ [%s] DB is back online", database.Name),
fmt.Sprintf("✅ [%s] DB is back online", database.Name),
)
})

View File

@@ -1,7 +1,8 @@
package restores
import (
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/backups/backups"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/restores/usecases"
"postgresus-backend/internal/features/storages"
"postgresus-backend/internal/features/users"
@@ -13,6 +14,7 @@ var restoreService = &RestoreService{
backups.GetBackupService(),
restoreRepository,
storages.GetStorageService(),
backups_config.GetBackupConfigService(),
usecases.GetRestoreBackupUsecase(),
logger.GetLogger(),
}
@@ -33,3 +35,7 @@ func GetRestoreController() *RestoreController {
func GetRestoreBackgroundService() *RestoreBackgroundService {
return restoreBackgroundService
}
func SetupDependencies() {
backups.GetBackupService().AddBackupRemoveListener(restoreService)
}

View File

@@ -1,7 +1,7 @@
package models
import (
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/backups/backups"
"postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/restores/enums"
"time"

View File

@@ -3,7 +3,8 @@ package restores
import (
"errors"
"log/slog"
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/backups/backups"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/restores/enums"
"postgresus-backend/internal/features/restores/models"
@@ -19,10 +20,32 @@ type RestoreService struct {
backupService *backups.BackupService
restoreRepository *RestoreRepository
storageService *storages.StorageService
backupConfigService *backups_config.BackupConfigService
restoreBackupUsecase *usecases.RestoreBackupUsecase
logger *slog.Logger
}
func (s *RestoreService) OnBeforeBackupRemove(backup *backups.Backup) error {
restores, err := s.restoreRepository.FindByBackupID(backup.ID)
if err != nil {
return err
}
for _, restore := range restores {
if restore.Status == enums.RestoreStatusInProgress {
return errors.New("restore is in progress, backup cannot be removed")
}
}
for _, restore := range restores {
if err := s.restoreRepository.DeleteByID(restore.ID); err != nil {
return err
}
}
return nil
}
func (s *RestoreService) GetRestores(
user *users_models.User,
backupID uuid.UUID,
@@ -110,9 +133,17 @@ func (s *RestoreService) RestoreBackup(
return err
}
backupConfig, err := s.backupConfigService.GetBackupConfigByDbId(
backup.Database.ID,
)
if err != nil {
return err
}
start := time.Now().UTC()
err = s.restoreBackupUsecase.Execute(
backupConfig,
restore,
backup,
storage,

View File

@@ -14,7 +14,8 @@ import (
"time"
"postgresus-backend/internal/config"
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/backups/backups"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/restores/models"
@@ -29,6 +30,7 @@ type RestorePostgresqlBackupUsecase struct {
}
func (uc *RestorePostgresqlBackupUsecase) Execute(
backupConfig *backups_config.BackupConfig,
restore models.Restore,
backup *backups.Backup,
storage *storages.Storage,
@@ -56,7 +58,7 @@ func (uc *RestorePostgresqlBackupUsecase) Execute(
// Use parallel jobs based on CPU count (same as backup)
// Cap between 1 and 8 to avoid overwhelming the server
parallelJobs := max(1, min(pg.CpuCount, 8))
parallelJobs := max(1, min(backupConfig.CpuCount, 8))
args := []string{
"-Fc", // expect custom format (same as backup)

View File

@@ -2,7 +2,8 @@ package usecases
import (
"errors"
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/backups/backups"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
"postgresus-backend/internal/features/restores/models"
usecases_postgresql "postgresus-backend/internal/features/restores/usecases/postgresql"
@@ -14,12 +15,18 @@ type RestoreBackupUsecase struct {
}
func (uc *RestoreBackupUsecase) Execute(
backupConfig *backups_config.BackupConfig,
restore models.Restore,
backup *backups.Backup,
storage *storages.Storage,
) error {
if restore.Backup.Database.Type == databases.DatabaseTypePostgres {
return uc.restorePostgresqlBackupUsecase.Execute(restore, backup, storage)
return uc.restorePostgresqlBackupUsecase.Execute(
backupConfig,
restore,
backup,
storage,
)
}
return errors.New("database type not supported")

View File

@@ -38,17 +38,32 @@ func (s *GoogleDriveStorage) SaveFile(
ctx := context.Background()
filename := fileID.String()
// Ensure the postgresus_backups folder exists
folderID, err := s.ensureBackupsFolderExists(ctx, driveService)
if err != nil {
return fmt.Errorf("failed to create/find backups folder: %w", err)
}
// Delete any previous copy so we keep at most one object per logical file.
_ = s.deleteByName(ctx, driveService, filename) // ignore "not found"
_ = s.deleteByName(ctx, driveService, filename, folderID) // ignore "not found"
fileMeta := &drive.File{Name: filename}
fileMeta := &drive.File{
Name: filename,
Parents: []string{folderID},
}
_, err := driveService.Files.Create(fileMeta).Media(file).Context(ctx).Do()
_, err = driveService.Files.Create(fileMeta).Media(file).Context(ctx).Do()
if err != nil {
return fmt.Errorf("failed to upload file to Google Drive: %w", err)
}
logger.Info("file uploaded to Google Drive", "name", filename)
logger.Info(
"file uploaded to Google Drive",
"name",
filename,
"folder",
"postgresus_backups",
)
return nil
})
}
@@ -56,7 +71,12 @@ func (s *GoogleDriveStorage) SaveFile(
func (s *GoogleDriveStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
var result io.ReadCloser
err := s.withRetryOnAuth(func(driveService *drive.Service) error {
fileIDGoogle, err := s.lookupFileID(driveService, fileID.String())
folderID, err := s.findBackupsFolder(driveService)
if err != nil {
return fmt.Errorf("failed to find backups folder: %w", err)
}
fileIDGoogle, err := s.lookupFileID(driveService, fileID.String(), folderID)
if err != nil {
return err
}
@@ -76,7 +96,12 @@ func (s *GoogleDriveStorage) GetFile(fileID uuid.UUID) (io.ReadCloser, error) {
func (s *GoogleDriveStorage) DeleteFile(fileID uuid.UUID) error {
return s.withRetryOnAuth(func(driveService *drive.Service) error {
ctx := context.Background()
return s.deleteByName(ctx, driveService, fileID.String())
folderID, err := s.findBackupsFolder(driveService)
if err != nil {
return fmt.Errorf("failed to find backups folder: %w", err)
}
return s.deleteByName(ctx, driveService, fileID.String(), folderID)
})
}
@@ -109,8 +134,17 @@ func (s *GoogleDriveStorage) TestConnection() error {
testFilename := "test-connection-" + uuid.New().String()
testData := []byte("test")
// Ensure the postgresus_backups folder exists
folderID, err := s.ensureBackupsFolderExists(ctx, driveService)
if err != nil {
return fmt.Errorf("failed to create/find backups folder: %w", err)
}
// Test write operation
fileMeta := &drive.File{Name: testFilename}
fileMeta := &drive.File{
Name: testFilename,
Parents: []string{folderID},
}
file, err := driveService.Files.Create(fileMeta).
Media(strings.NewReader(string(testData))).
Context(ctx).
@@ -358,8 +392,13 @@ func (s *GoogleDriveStorage) getDriveService() (*drive.Service, error) {
func (s *GoogleDriveStorage) lookupFileID(
driveService *drive.Service,
name string,
folderID string,
) (string, error) {
query := fmt.Sprintf("name = '%s' and trashed = false", escapeForQuery(name))
query := fmt.Sprintf(
"name = '%s' and trashed = false and '%s' in parents",
escapeForQuery(name),
folderID,
)
results, err := driveService.Files.List().
Q(query).
@@ -371,7 +410,7 @@ func (s *GoogleDriveStorage) lookupFileID(
}
if len(results.Files) == 0 {
return "", fmt.Errorf("file %q not found in Google Drive", name)
return "", fmt.Errorf("file %q not found in Google Drive backups folder", name)
}
return results.Files[0].Id, nil
@@ -381,8 +420,13 @@ func (s *GoogleDriveStorage) deleteByName(
ctx context.Context,
driveService *drive.Service,
name string,
folderID string,
) error {
query := fmt.Sprintf("name = '%s' and trashed = false", escapeForQuery(name))
query := fmt.Sprintf(
"name = '%s' and trashed = false and '%s' in parents",
escapeForQuery(name),
folderID,
)
err := driveService.
Files.
@@ -409,3 +453,47 @@ func (s *GoogleDriveStorage) deleteByName(
func escapeForQuery(s string) string {
return strings.ReplaceAll(s, `'`, `\'`)
}
// ensureBackupsFolderExists creates the postgresus_backups folder if it doesn't exist
func (s *GoogleDriveStorage) ensureBackupsFolderExists(
ctx context.Context,
driveService *drive.Service,
) (string, error) {
folderID, err := s.findBackupsFolder(driveService)
if err == nil {
return folderID, nil
}
// Folder doesn't exist, create it
folderMeta := &drive.File{
Name: "postgresus_backups",
MimeType: "application/vnd.google-apps.folder",
}
folder, err := driveService.Files.Create(folderMeta).Context(ctx).Do()
if err != nil {
return "", fmt.Errorf("failed to create postgresus_backups folder: %w", err)
}
return folder.Id, nil
}
// findBackupsFolder finds the postgresus_backups folder ID
func (s *GoogleDriveStorage) findBackupsFolder(driveService *drive.Service) (string, error) {
query := "name = 'postgresus_backups' and mimeType = 'application/vnd.google-apps.folder' and trashed = false"
results, err := driveService.Files.List().
Q(query).
Fields("files(id)").
PageSize(1).
Do()
if err != nil {
return "", fmt.Errorf("failed to search for backups folder: %w", err)
}
if len(results.Files) == 0 {
return "", fmt.Errorf("postgresus_backups folder not found")
}
return results.Files[0].Id, nil
}

View File

@@ -1,7 +1,7 @@
package system_healthcheck
import (
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/backups/backups"
"postgresus-backend/internal/features/disk"
)

View File

@@ -2,7 +2,7 @@ package system_healthcheck
import (
"errors"
"postgresus-backend/internal/features/backups"
"postgresus-backend/internal/features/backups/backups"
"postgresus-backend/internal/features/disk"
"postgresus-backend/internal/storage"
)

View File

@@ -5,14 +5,17 @@ import (
"os"
"path/filepath"
"postgresus-backend/internal/config"
"postgresus-backend/internal/features/backups"
usecases_postgresql_backup "postgresus-backend/internal/features/backups/usecases/postgresql"
"postgresus-backend/internal/features/backups/backups"
usecases_postgresql_backup "postgresus-backend/internal/features/backups/backups/usecases/postgresql"
backups_config "postgresus-backend/internal/features/backups/config"
"postgresus-backend/internal/features/databases"
pgtypes "postgresus-backend/internal/features/databases/databases/postgresql"
"postgresus-backend/internal/features/intervals"
"postgresus-backend/internal/features/restores/models"
usecases_postgresql_restore "postgresus-backend/internal/features/restores/usecases/postgresql"
"postgresus-backend/internal/features/storages"
local_storage "postgresus-backend/internal/features/storages/models/local"
"postgresus-backend/internal/util/period"
"postgresus-backend/internal/util/tools"
"strconv"
"testing"
@@ -99,7 +102,7 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
backupID := uuid.New()
pgVersionEnum := tools.GetPostgresqlVersionEnum(pgVersion)
backupDbConfig := &databases.Database{
backupDb := &databases.Database{
ID: uuid.New(),
Type: databases.DatabaseTypePostgres,
Name: "Test Database",
@@ -111,10 +114,19 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
Password: container.Password,
Database: &container.Database,
IsHttps: false,
CpuCount: 1,
},
}
storageID := uuid.New()
backupConfig := &backups_config.BackupConfig{
DatabaseID: backupDb.ID,
IsBackupsEnabled: true,
StorePeriod: period.PeriodDay,
BackupInterval: &intervals.Interval{Interval: intervals.IntervalDaily},
StorageID: &storageID,
CpuCount: 1,
}
storage := &storages.Storage{
UserID: uuid.New(),
Type: storages.StorageTypeLocal,
@@ -126,7 +138,8 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
progressTracker := func(completedMBs float64) {}
err = usecases_postgresql_backup.GetCreatePostgresqlBackupUsecase().Execute(
backupID,
backupDbConfig,
backupConfig,
backupDb,
storage,
progressTracker,
)
@@ -150,12 +163,12 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
// Setup data for restore
completedBackup := &backups.Backup{
ID: backupID,
DatabaseID: backupDbConfig.ID,
DatabaseID: backupDb.ID,
StorageID: storage.ID,
Status: backups.BackupStatusCompleted,
CreatedAt: time.Now().UTC(),
Storage: storage,
Database: backupDbConfig,
Database: backupDb,
}
restoreID := uuid.New()
@@ -170,13 +183,12 @@ func testBackupRestoreForVersion(t *testing.T, pgVersion string, port string) {
Password: container.Password,
Database: &newDBName,
IsHttps: false,
CpuCount: 1,
},
}
// Restore the backup
restoreBackupUC := usecases_postgresql_restore.GetRestorePostgresqlBackupUsecase()
err = restoreBackupUC.Execute(restore, completedBackup, storage)
err = restoreBackupUC.Execute(backupConfig, restore, completedBackup, storage)
assert.NoError(t, err)
// Verify restored table exists

View 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))
}
}

View 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

View 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);
},
};

View File

@@ -1,3 +1,6 @@
export { backupsApi } from './api/backupsApi';
export { backupConfigApi } from './api/backupConfigApi';
export { BackupStatus } from './model/BackupStatus';
export type { Backup } from './model/Backup';
export type { BackupConfig } from './model/BackupConfig';
export { BackupNotificationType } from './model/BackupNotificationType';

View 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;
}

View File

@@ -0,0 +1,4 @@
export enum BackupNotificationType {
BackupFailed = 'BACKUP_FAILED',
BackupSuccess = 'BACKUP_SUCCESS',
}

View File

@@ -77,17 +77,4 @@ export const databaseApi = {
)
.then((res) => res.isUsing);
},
async isStorageUsing(storageId: string): Promise<boolean> {
const requestOptions: RequestOptions = new RequestOptions();
return apiHelper
.fetchGetJson<{
isUsing: boolean;
}>(
`${getApplicationServer()}/api/v1/databases/storage/${storageId}/is-using`,
requestOptions,
true,
)
.then((res) => res.isUsing);
},
};

View File

@@ -1,4 +0,0 @@
export enum BackupNotificationType {
BACKUP_FAILED = 'BACKUP_FAILED',
BACKUP_SUCCESS = 'BACKUP_SUCCESS',
}

View File

@@ -1,26 +1,16 @@
import type { Interval } from '../../intervals';
import type { Notifier } from '../../notifiers';
import type { BackupNotificationType } from './BackupNotificationType';
import type { DatabaseType } from './DatabaseType';
import type { HealthStatus } from './HealthStatus';
import type { Period } from './Period';
import type { PostgresqlDatabase } from './postgresql/PostgresqlDatabase';
export interface Database {
id: string;
name: string;
type: DatabaseType;
backupInterval?: Interval;
storePeriod: Period;
postgresql?: PostgresqlDatabase;
storage: Storage;
notifiers: Notifier[];
sendNotificationsOn: BackupNotificationType[];
lastBackupTime?: Date;
lastBackupErrorMessage?: string;

View File

@@ -11,6 +11,4 @@ export interface PostgresqlDatabase {
password: string;
database?: string;
isHttps: boolean;
cpuCount: number;
}

View File

@@ -1 +1,3 @@
export { BackupsComponent } from './ui/BackupsComponent';
export { EditBackupConfigComponent } from './ui/EditBackupConfigComponent';
export { ShowBackupConfigComponent } from './ui/ShowBackupConfigComponent';

View File

@@ -6,12 +6,12 @@ import {
InfoCircleOutlined,
SyncOutlined,
} from '@ant-design/icons';
import { Button, Modal, Table, Tooltip } from 'antd';
import { Button, Modal, Spin, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { useEffect, useRef, useState } from 'react';
import { type Backup, BackupStatus, backupsApi } from '../../../entity/backups';
import { type Backup, BackupStatus, backupConfigApi, backupsApi } from '../../../entity/backups';
import type { Database } from '../../../entity/databases';
import { getUserTimeFormat } from '../../../shared/time';
import { ConfirmationComponent } from '../../../shared/ui';
@@ -22,9 +22,12 @@ interface Props {
}
export const BackupsComponent = ({ database }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [isBackupsLoading, setIsBackupsLoading] = useState(false);
const [backups, setBackups] = useState<Backup[]>([]);
const [isBackupConfigLoading, setIsBackupConfigLoading] = useState(false);
const [isShowBackupConfig, setIsShowBackupConfig] = useState(false);
const [isMakeBackupRequestLoading, setIsMakeBackupRequestLoading] = useState(false);
const [showingBackupError, setShowingBackupError] = useState<Backup | undefined>();
@@ -86,11 +89,26 @@ export const BackupsComponent = ({ database }: Props) => {
};
useEffect(() => {
setIsLoading(true);
loadBackups().then(() => setIsLoading(false));
let isBackupsEnabled = false;
setIsBackupConfigLoading(true);
backupConfigApi.getBackupConfigByDbID(database.id).then((backupConfig) => {
setIsBackupConfigLoading(false);
if (backupConfig.isBackupsEnabled) {
// load backups
isBackupsEnabled = true;
setIsShowBackupConfig(true);
setIsBackupsLoading(true);
loadBackups().then(() => setIsBackupsLoading(false));
}
});
const interval = setInterval(() => {
loadBackups();
if (isBackupsEnabled) {
loadBackups();
}
}, 1_000);
return () => clearInterval(interval);
@@ -264,8 +282,20 @@ export const BackupsComponent = ({ database }: Props) => {
},
];
if (isBackupConfigLoading) {
return (
<div className="mb-5 flex items-center">
<Spin />
</div>
);
}
if (!isShowBackupConfig) {
return <div />;
}
return (
<div>
<div className="mt-5 w-full rounded bg-white p-5 shadow">
<h2 className="text-xl font-bold">Backups</h2>
<div className="mt-5" />
@@ -288,7 +318,7 @@ export const BackupsComponent = ({ database }: Props) => {
columns={columns}
dataSource={backups}
rowKey="id"
loading={isLoading}
loading={isBackupsLoading}
size="small"
pagination={false}
/>

View 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>
);
};

View 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>
);
};

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { backupsApi } from '../../../entity/backups';
import { type BackupConfig, backupConfigApi, backupsApi } from '../../../entity/backups';
import {
type Database,
DatabaseType,
@@ -8,10 +8,10 @@ import {
type PostgresqlDatabase,
databaseApi,
} from '../../../entity/databases';
import { EditBackupConfigComponent } from '../../backups';
import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent';
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
import { EditDatabaseStorageComponent } from './edit/EditDatabaseStorageComponent';
interface Props {
onCreated: () => void;
@@ -21,6 +21,7 @@ interface Props {
export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => {
const [isCreating, setIsCreating] = useState(false);
const [backupConfig, setBackupConfig] = useState<BackupConfig | undefined>();
const [database, setDatabase] = useState<Database>({
id: undefined as unknown as string,
name: '',
@@ -38,18 +39,23 @@ export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => {
sendNotificationsOn: [],
} as Database);
const [step, setStep] = useState<'base-info' | 'db-settings' | 'storages' | 'notifiers'>(
const [step, setStep] = useState<'base-info' | 'db-settings' | 'backup-config' | 'notifiers'>(
'base-info',
);
const createDatabase = async (database: Database) => {
const createDatabase = async (database: Database, backupConfig: BackupConfig) => {
setIsCreating(true);
try {
const createdDatabase = await databaseApi.createDatabase(database);
setDatabase({ ...createdDatabase });
await backupsApi.makeBackup(createdDatabase.id);
backupConfig.databaseId = createdDatabase.id;
await backupConfigApi.saveBackupConfig(backupConfig);
if (backupConfig.isBackupsEnabled) {
await backupsApi.makeBackup(createdDatabase.id);
}
onCreated();
onClose();
} catch (error) {
@@ -89,25 +95,24 @@ export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => {
isSaveToApi={false}
onSaved={(database) => {
setDatabase({ ...database });
setStep('storages');
setStep('backup-config');
}}
/>
);
}
if (step === 'storages') {
if (step === 'backup-config') {
return (
<EditDatabaseStorageComponent
<EditBackupConfigComponent
database={database}
isShowCancelButton={false}
onCancel={() => onClose()}
isShowBackButton
onBack={() => setStep('db-settings')}
isShowSaveOnlyForUnsaved={false}
saveButtonText="Continue"
isSaveToApi={false}
onSaved={(database) => {
setDatabase({ ...database });
onSaved={(backupConfig) => {
setBackupConfig(backupConfig);
setStep('notifiers');
}}
/>
@@ -121,7 +126,7 @@ export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => {
isShowCancelButton={false}
onCancel={() => onClose()}
isShowBackButton
onBack={() => setStep('storages')}
onBack={() => setStep('backup-config')}
isShowSaveOnlyForUnsaved={false}
saveButtonText="Complete"
isSaveToApi={false}
@@ -129,7 +134,7 @@ export const CreateDatabaseComponent = ({ onCreated, onClose }: Props) => {
if (isCreating) return;
setDatabase({ ...database });
createDatabase(database);
createDatabase(database, backupConfig!);
}}
/>
);

View File

@@ -3,7 +3,6 @@ import dayjs from 'dayjs';
import { type Database, DatabaseType } from '../../../entity/databases';
import { HealthStatus } from '../../../entity/databases/model/HealthStatus';
import { getStorageLogoFromType } from '../../../entity/storages';
import { getUserShortTimeFormat } from '../../../shared/time/getUserTimeFormat';
interface Props {
@@ -52,16 +51,6 @@ export const DatabaseCardComponent = ({
<img src={databaseIcon} alt="databaseIcon" className="ml-1 h-4 w-4" />
</div>
<div className="mb flex items-center">
<div className="text-sm text-gray-500">Store to: {database.storage?.name} </div>
<img
src={getStorageLogoFromType(database.storage?.type)}
alt="databaseIcon"
className="ml-1 h-4 w-4"
/>
</div>
{database.lastBackupTime && (
<div className="mt-3 mb-1 text-xs text-gray-500">
<span className="font-bold">Last backup</span>

View File

@@ -6,20 +6,20 @@ import { useEffect } from 'react';
import { type Database, databaseApi } from '../../../entity/databases';
import { ToastHelper } from '../../../shared/toast';
import { ConfirmationComponent } from '../../../shared/ui';
import { BackupsComponent } from '../../backups';
import {
BackupsComponent,
EditBackupConfigComponent,
ShowBackupConfigComponent,
} from '../../backups';
import {
EditHealthcheckConfigComponent,
HealthckeckAttemptsComponent,
ShowHealthcheckConfigComponent,
} from '../../healthcheck';
import { EditDatabaseBaseInfoComponent } from './edit/EditDatabaseBaseInfoComponent';
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
import { EditDatabaseStorageComponent } from './edit/EditDatabaseStorageComponent';
import { ShowDatabaseBaseInfoComponent } from './show/ShowDatabaseBaseInfoComponent';
import { ShowDatabaseNotifiersComponent } from './show/ShowDatabaseNotifiersComponent';
import { ShowDatabaseSpecificDataComponent } from './show/ShowDatabaseSpecificDataComponent';
import { ShowDatabaseStorageComponent } from './show/ShowDatabaseStorageComponent';
interface Props {
contentHeight: number;
@@ -37,10 +37,9 @@ export const DatabaseComponent = ({
const [database, setDatabase] = useState<Database | undefined>();
const [isEditName, setIsEditName] = useState(false);
const [isEditBaseSettings, setIsEditBaseSettings] = useState(false);
const [isEditDatabaseSpecificDataSettings, setIsEditDatabaseSpecificDataSettings] =
useState(false);
const [isEditStorageSettings, setIsEditStorageSettings] = useState(false);
const [isEditBackupConfig, setIsEditBackupConfig] = useState(false);
const [isEditNotifiersSettings, setIsEditNotifiersSettings] = useState(false);
const [isEditHealthcheckSettings, setIsEditHealthcheckSettings] = useState(false);
@@ -95,14 +94,11 @@ export const DatabaseComponent = ({
});
};
const startEdit = (
type: 'name' | 'settings' | 'database' | 'storage' | 'notifiers' | 'healthcheck',
) => {
const startEdit = (type: 'name' | 'database' | 'backup-config' | 'notifiers' | 'healthcheck') => {
setEditDatabase(JSON.parse(JSON.stringify(database)));
setIsEditName(type === 'name');
setIsEditBaseSettings(type === 'settings');
setIsEditDatabaseSpecificDataSettings(type === 'database');
setIsEditStorageSettings(type === 'storage');
setIsEditBackupConfig(type === 'backup-config');
setIsEditNotifiersSettings(type === 'notifiers');
setIsEditHealthcheckSettings(type === 'healthcheck');
setIsNameUnsaved(false);
@@ -222,41 +218,6 @@ export const DatabaseComponent = ({
)}
<div className="flex flex-wrap gap-10">
<div className="w-[350px]">
<div className="mt-5 flex items-center font-bold">
<div>Backup settings</div>
{!isEditBaseSettings ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('settings')}
>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div className="mt-1 text-sm">
{isEditBaseSettings ? (
<EditDatabaseBaseInfoComponent
isShowName={false}
database={database}
isShowCancelButton
onCancel={() => {
setIsEditBaseSettings(false);
loadSettings();
}}
isSaveToApi={true}
onSaved={onDatabaseChanged}
/>
) : (
<ShowDatabaseBaseInfoComponent database={database} />
)}
</div>
</div>
<div className="w-[350px]">
<div className="mt-5 flex items-center font-bold">
<div>Database settings</div>
@@ -292,17 +253,15 @@ export const DatabaseComponent = ({
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-10">
<div className="w-[350px]">
<div className="mt-5 flex items-center font-bold">
<div>Storage settings</div>
<div>Backup config</div>
{!isEditStorageSettings ? (
{!isEditBackupConfig ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('storage')}
onClick={() => startEdit('backup-config')}
>
<img src="/icons/pen-gray.svg" />
</div>
@@ -313,26 +272,58 @@ export const DatabaseComponent = ({
<div>
<div className="mt-1 text-sm">
{isEditStorageSettings ? (
<EditDatabaseStorageComponent
{isEditBackupConfig ? (
<EditBackupConfigComponent
database={database}
isShowCancelButton
isShowBackButton={false}
isShowSaveOnlyForUnsaved={true}
onBack={() => {}}
onCancel={() => {
setIsEditStorageSettings(false);
setIsEditBackupConfig(false);
loadSettings();
}}
isSaveToApi={true}
onSaved={onDatabaseChanged}
onSaved={() => onDatabaseChanged(database)}
isShowBackButton={false}
onBack={() => {}}
/>
) : (
<ShowDatabaseStorageComponent database={database} />
<ShowBackupConfigComponent database={database} />
)}
</div>
</div>
</div>
</div>
<div className="flex flex-wrap gap-10">
<div className="w-[350px]">
<div className="mt-5 flex items-center font-bold">
<div>Healthcheck settings</div>
{!isEditHealthcheckSettings ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('healthcheck')}
>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div className="mt-1 text-sm">
{isEditHealthcheckSettings ? (
<EditHealthcheckConfigComponent
databaseId={database.id}
onClose={() => {
setIsEditHealthcheckSettings(false);
loadSettings();
}}
/>
) : (
<ShowHealthcheckConfigComponent databaseId={database.id} />
)}
</div>
</div>
<div className="w-[350px]">
<div className="mt-5 flex items-center font-bold">
@@ -373,39 +364,6 @@ export const DatabaseComponent = ({
</div>
</div>
<div className="flex flex-wrap gap-10">
<div className="w-[350px]">
<div className="mt-5 flex items-center font-bold">
<div>Healthcheck settings</div>
{!isEditHealthcheckSettings ? (
<div
className="ml-2 h-4 w-4 cursor-pointer"
onClick={() => startEdit('healthcheck')}
>
<img src="/icons/pen-gray.svg" />
</div>
) : (
<div />
)}
</div>
<div className="mt-1 text-sm">
{isEditHealthcheckSettings ? (
<EditHealthcheckConfigComponent
databaseId={database.id}
onClose={() => {
setIsEditHealthcheckSettings(false);
loadSettings();
}}
/>
) : (
<ShowHealthcheckConfigComponent databaseId={database.id} />
)}
</div>
</div>
</div>
{!isEditDatabaseSpecificDataSettings && (
<div className="mt-10">
<Button
@@ -445,13 +403,8 @@ export const DatabaseComponent = ({
)}
</div>
<div className="mt-5 w-full rounded bg-white p-5 shadow">
{database && <HealthckeckAttemptsComponent database={database} />}
</div>
<div className="mt-5 w-full rounded bg-white p-5 shadow">
{database && <BackupsComponent database={database} />}
</div>
{database && <HealthckeckAttemptsComponent database={database} />}
{database && <BackupsComponent database={database} />}
</div>
);
};

View File

@@ -1,28 +1,7 @@
import { InfoCircleOutlined } from '@ant-design/icons';
import { Button, Input, InputNumber, Select, TimePicker, Tooltip } from 'antd';
import dayjs, { Dayjs } from 'dayjs';
import { useEffect, useMemo, useState } from 'react';
import { Button, Input } from 'antd';
import { useEffect, useState } from 'react';
import { type Database, databaseApi } from '../../../../entity/databases';
import { Period } from '../../../../entity/databases/model/Period';
import { type Interval, IntervalType } from '../../../../entity/intervals';
import {
getLocalDayOfMonth,
getLocalWeekday,
getUserTimeFormat,
getUtcDayOfMonth,
getUtcWeekday,
} from '../../../../shared/time/utils';
const weekdayOptions = [
{ value: 1, label: 'Mon' },
{ value: 2, label: 'Tue' },
{ value: 3, label: 'Wed' },
{ value: 4, label: 'Thu' },
{ value: 5, label: 'Fri' },
{ value: 6, label: 'Sat' },
{ value: 7, label: 'Sun' },
];
interface Props {
database: Database;
@@ -49,32 +28,11 @@ export const EditDatabaseBaseInfoComponent = ({
const [isUnsaved, setIsUnsaved] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const timeFormat = useMemo(() => {
const is12 = getUserTimeFormat();
return { use12Hours: is12, format: is12 ? 'h:mm A' : 'HH:mm' };
}, []);
const updateDatabase = (patch: Partial<Database>) => {
setEditingDatabase((prev) => (prev ? { ...prev, ...patch } : prev));
setIsUnsaved(true);
};
const saveInterval = (patch: Partial<Interval>) => {
setEditingDatabase((prev) => {
if (!prev) return prev;
const updatedBackupInterval = { ...(prev.backupInterval ?? {}), ...patch };
if (!updatedBackupInterval.id && prev.backupInterval?.id) {
updatedBackupInterval.id = prev.backupInterval.id;
}
return { ...prev, backupInterval: updatedBackupInterval as Interval };
});
setIsUnsaved(true);
};
const saveDatabase = async () => {
if (!editingDatabase) return;
if (isSaveToApi) {
@@ -97,35 +55,9 @@ export const EditDatabaseBaseInfoComponent = ({
}, [database]);
if (!editingDatabase) return null;
const { backupInterval } = editingDatabase;
// UTC → local conversions for display
const localTime: Dayjs | undefined = backupInterval?.timeOfDay
? dayjs.utc(backupInterval.timeOfDay, 'HH:mm').local()
: undefined;
const displayedWeekday: number | undefined =
backupInterval?.interval === IntervalType.WEEKLY &&
backupInterval.weekday &&
backupInterval.timeOfDay
? getLocalWeekday(backupInterval.weekday, backupInterval.timeOfDay)
: backupInterval?.weekday;
const displayedDayOfMonth: number | undefined =
backupInterval?.interval === IntervalType.MONTHLY &&
backupInterval.dayOfMonth &&
backupInterval.timeOfDay
? getLocalDayOfMonth(backupInterval.dayOfMonth, backupInterval.timeOfDay)
: backupInterval?.dayOfMonth;
// mandatory-field check
const isAllFieldsFilled =
Boolean(editingDatabase.name) &&
Boolean(editingDatabase.storePeriod) &&
Boolean(backupInterval?.interval) &&
(!backupInterval ||
((backupInterval.interval !== IntervalType.WEEKLY || displayedWeekday) &&
(backupInterval.interval !== IntervalType.MONTHLY || displayedDayOfMonth)));
const isAllFieldsFilled = Boolean(editingDatabase.name);
return (
<div>
@@ -142,119 +74,13 @@ export const EditDatabaseBaseInfoComponent = ({
</div>
)}
<div className="mt-4 mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup interval</div>
<Select
value={backupInterval?.interval}
onChange={(v) => saveInterval({ interval: v })}
size="small"
className="max-w-[200px] grow"
options={[
{ label: 'Hourly', value: IntervalType.HOURLY },
{ label: 'Daily', value: IntervalType.DAILY },
{ label: 'Weekly', value: IntervalType.WEEKLY },
{ label: 'Monthly', value: IntervalType.MONTHLY },
]}
/>
</div>
{backupInterval?.interval === IntervalType.WEEKLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup weekday</div>
<Select
value={displayedWeekday}
onChange={(localWeekday) => {
if (!localWeekday) return;
const ref = localTime ?? dayjs();
saveInterval({ weekday: getUtcWeekday(localWeekday, ref) });
}}
size="small"
className="max-w-[200px] grow"
options={weekdayOptions}
/>
</div>
)}
{backupInterval?.interval === IntervalType.MONTHLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup day of month</div>
<InputNumber
min={1}
max={31}
value={displayedDayOfMonth}
onChange={(localDom) => {
if (!localDom) return;
const ref = localTime ?? dayjs();
saveInterval({ dayOfMonth: getUtcDayOfMonth(localDom, ref) });
}}
size="small"
className="max-w-[200px] grow"
/>
</div>
)}
{backupInterval?.interval !== IntervalType.HOURLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup time of day</div>
<TimePicker
value={localTime}
format={timeFormat.format}
use12Hours={timeFormat.use12Hours}
allowClear={false}
size="small"
className="max-w-[200px] grow"
onChange={(t) => {
if (!t) return;
const patch: Partial<Interval> = { timeOfDay: t.utc().format('HH:mm') };
if (backupInterval?.interval === IntervalType.WEEKLY && displayedWeekday) {
patch.weekday = getUtcWeekday(displayedWeekday, t);
}
if (backupInterval?.interval === IntervalType.MONTHLY && displayedDayOfMonth) {
patch.dayOfMonth = getUtcDayOfMonth(displayedDayOfMonth, t);
}
saveInterval(patch);
}}
/>
</div>
)}
<div className="mt-4 mb-1 flex w-full items-center">
<div className="min-w-[150px]">Store period</div>
<Select
value={editingDatabase.storePeriod}
onChange={(v) => updateDatabase({ storePeriod: v })}
size="small"
className="max-w-[200px] grow"
options={[
{ label: '1 day', value: Period.DAY },
{ label: '1 week', value: Period.WEEK },
{ label: '1 month', value: Period.MONTH },
{ label: '3 months', value: Period.THREE_MONTH },
{ label: '6 months', value: Period.SIX_MONTH },
{ label: '1 year', value: Period.YEAR },
{ label: '2 years', value: Period.TWO_YEARS },
{ label: '3 years', value: Period.THREE_YEARS },
{ label: '4 years', value: Period.FOUR_YEARS },
{ label: '5 years', value: Period.FIVE_YEARS },
{ label: 'Forever', value: Period.FOREVER },
]}
/>
<Tooltip
className="cursor-pointer"
title="How long to keep the backups? Make sure you have enough storage space."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
<div className="mt-5 flex">
{isShowCancelButton && (
<Button danger ghost className="mr-1" onClick={onCancel}>
Cancel
</Button>
)}
<Button
type="primary"
className={`${isShowCancelButton ? 'ml-1' : 'ml-auto'} mr-5`}

View File

@@ -2,7 +2,6 @@ import { Button, Modal, Select, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { type Database, databaseApi } from '../../../../entity/databases';
import { BackupNotificationType } from '../../../../entity/databases/model/BackupNotificationType';
import { type Notifier, notifierApi } from '../../../../entity/notifiers';
import { EditNotifierComponent } from '../../../notifiers/ui/edit/EditNotifierComponent';
@@ -99,35 +98,6 @@ export const EditDatabaseNotifiersComponent = ({
You can select several notifiers, notifications will be sent to all of them.
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Sent notification when</div>
<Select
mode="multiple"
value={editingDatabase.sendNotificationsOn}
onChange={(sendNotificationsOn) => {
setEditingDatabase({
...editingDatabase,
sendNotificationsOn,
} as unknown as Database);
setIsUnsaved(true);
}}
size="small"
className="max-w-[200px] grow"
options={[
{
label: 'Backup failed',
value: BackupNotificationType.BACKUP_FAILED,
},
{
label: 'Backup success',
value: BackupNotificationType.BACKUP_SUCCESS,
},
]}
/>
</div>
<div className="mb-5 flex w-full items-center">
<div className="min-w-[150px]">Notifiers</div>

View File

@@ -26,6 +26,7 @@ interface Props {
isShowDbVersionHint?: boolean;
isShowDbName?: boolean;
isBlockDbName?: boolean;
}
export const EditDatabaseSpecificDataComponent = ({
@@ -43,6 +44,8 @@ export const EditDatabaseSpecificDataComponent = ({
isShowDbVersionHint = true,
isShowDbName = true,
isBlockDbName = false,
}: Props) => {
const [editingDatabase, setEditingDatabase] = useState<Database>();
const [isSaving, setIsSaving] = useState(false);
@@ -263,6 +266,7 @@ export const EditDatabaseSpecificDataComponent = ({
size="small"
className="max-w-[200px] grow"
placeholder="Enter PG database name (optional)"
disabled={isBlockDbName}
/>
</div>
)}
@@ -283,33 +287,6 @@ export const EditDatabaseSpecificDataComponent = ({
size="small"
/>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">CPU count</div>
<InputNumber
value={editingDatabase.postgresql?.cpuCount}
onChange={(e) => {
if (!editingDatabase.postgresql || e === null) return;
setEditingDatabase({
...editingDatabase,
postgresql: { ...editingDatabase.postgresql, cpuCount: e },
});
setIsConnectionTested(false);
}}
size="small"
className="max-w-[200px] grow"
placeholder="Enter PG CPU count"
min={1}
step={1}
/>
<Tooltip
className="cursor-pointer"
title="The amount of CPU can be utilized for backuping or restoring"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</>
)}

View File

@@ -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>
);
};

View File

@@ -1,84 +1,11 @@
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { type Database } from '../../../../entity/databases';
import { Period } from '../../../../entity/databases/model/Period';
import { IntervalType } from '../../../../entity/intervals';
import {
getLocalDayOfMonth,
getLocalWeekday,
getUserTimeFormat,
} from '../../../../shared/time/utils';
interface Props {
database: Database;
isShowName?: boolean;
}
const weekdayLabels = {
1: 'Mon',
2: 'Tue',
3: 'Wed',
4: 'Thu',
5: 'Fri',
6: 'Sat',
7: 'Sun',
};
const intervalLabels = {
[IntervalType.HOURLY]: 'Hourly',
[IntervalType.DAILY]: 'Daily',
[IntervalType.WEEKLY]: 'Weekly',
[IntervalType.MONTHLY]: 'Monthly',
};
const periodLabels = {
[Period.DAY]: '1 day',
[Period.WEEK]: '1 week',
[Period.MONTH]: '1 month',
[Period.THREE_MONTH]: '3 months',
[Period.SIX_MONTH]: '6 months',
[Period.YEAR]: '1 year',
[Period.TWO_YEARS]: '2 years',
[Period.THREE_YEARS]: '3 years',
[Period.FOUR_YEARS]: '4 years',
[Period.FIVE_YEARS]: '5 years',
[Period.FOREVER]: 'Forever',
};
export const ShowDatabaseBaseInfoComponent = ({ database, isShowName }: Props) => {
// Detect user's preferred time format (12-hour vs 24-hour)
const timeFormat = useMemo(() => {
const is12Hour = getUserTimeFormat();
return {
use12Hours: is12Hour,
format: is12Hour ? 'h:mm A' : 'HH:mm',
};
}, []);
const { backupInterval } = database;
const localTime = backupInterval?.timeOfDay
? dayjs.utc(backupInterval.timeOfDay, 'HH:mm').local()
: undefined;
const formattedTime = localTime ? localTime.format(timeFormat.format) : '';
// Convert UTC weekday/day-of-month to local equivalents for display
const displayedWeekday: number | undefined =
backupInterval?.interval === IntervalType.WEEKLY &&
backupInterval.weekday &&
backupInterval.timeOfDay
? getLocalWeekday(backupInterval.weekday, backupInterval.timeOfDay)
: backupInterval?.weekday;
const displayedDayOfMonth: number | undefined =
backupInterval?.interval === IntervalType.MONTHLY &&
backupInterval.dayOfMonth &&
backupInterval.timeOfDay
? getLocalDayOfMonth(backupInterval.dayOfMonth, backupInterval.timeOfDay)
: backupInterval?.dayOfMonth;
return (
<div>
{isShowName && (
@@ -87,39 +14,6 @@ export const ShowDatabaseBaseInfoComponent = ({ database, isShowName }: Props) =
<div>{database.name || ''}</div>
</div>
)}
<div className="mt-4 mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup interval</div>
<div>{backupInterval?.interval ? intervalLabels[backupInterval.interval] : ''}</div>
</div>
{backupInterval?.interval === IntervalType.WEEKLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup weekday</div>
<div>
{displayedWeekday ? weekdayLabels[displayedWeekday as keyof typeof weekdayLabels] : ''}
</div>
</div>
)}
{backupInterval?.interval === IntervalType.MONTHLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup day of month</div>
<div>{displayedDayOfMonth || ''}</div>
</div>
)}
{backupInterval?.interval !== IntervalType.HOURLY && (
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">Backup time of day</div>
<div>{formattedTime}</div>
</div>
)}
<div className="mt-4 mb-1 flex w-full items-center">
<div className="min-w-[150px]">Store period</div>
<div>{database.storePeriod ? periodLabels[database.storePeriod] : ''}</div>
</div>
</div>
);
};

View File

@@ -1,40 +1,27 @@
import { type Database } from '../../../../entity/databases';
import { BackupNotificationType } from '../../../../entity/databases/model/BackupNotificationType';
import { getNotifierLogoFromType } from '../../../../entity/notifiers/models/getNotifierLogoFromType';
interface Props {
database: Database;
}
const notificationTypeLabels = {
[BackupNotificationType.BACKUP_FAILED]: 'backup failed',
[BackupNotificationType.BACKUP_SUCCESS]: 'backup success',
};
export const ShowDatabaseNotifiersComponent = ({ database }: Props) => {
const notificationLabels =
database.sendNotificationsOn?.map((type) => notificationTypeLabels[type]).join(', ') || '';
return (
<div>
<div className="mb-2 flex w-full">
<div className="min-w-[150px]">Send notification when</div>
<div>
{notificationLabels.split(', ').map((label) => (
<div key={label}>- {label}</div>
))}
</div>
</div>
<div className="flex w-full">
<div className="min-w-[150px]">Notify to</div>
<div>
{database.notifiers?.map((notifier) => (
<div className="flex items-center" key={notifier.id}>
<div>- {notifier.name}</div>
<img src={getNotifierLogoFromType(notifier?.notifierType)} className="ml-1 h-4 w-4" />
</div>
))}
{database.notifiers && database.notifiers.length > 0 ? (
database.notifiers.map((notifier) => (
<div className="flex items-center" key={notifier.id}>
<div>- {notifier.name}</div>
<img src={getNotifierLogoFromType(notifier?.notifierType)} className="ml-1 h-4 w-4" />
</div>
))
) : (
<div className="text-gray-500">No notifiers configured</div>
)}
</div>
</div>
</div>

View File

@@ -64,11 +64,6 @@ export const ShowDatabaseSpecificDataComponent = ({ database }: Props) => {
<div className="min-w-[150px]">Use HTTPS</div>
<div>{database.postgresql?.isHttps ? 'Yes' : 'No'}</div>
</div>
<div className="mb-1 flex w-full items-center">
<div className="min-w-[150px]">CPU count</div>
<div>{database.postgresql?.cpuCount || ''}</div>
</div>
</>
)}
</div>

View File

@@ -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>
);
};

View File

@@ -4,7 +4,11 @@ import { useEffect, useState } from 'react';
import type { Database } from '../../../entity/databases';
import { HealthStatus } from '../../../entity/databases/model/HealthStatus';
import { type HealthcheckAttempt, healthcheckAttemptApi } from '../../../entity/healthcheck';
import {
type HealthcheckAttempt,
healthcheckAttemptApi,
healthcheckConfigApi,
} from '../../../entity/healthcheck';
import { getUserShortTimeFormat } from '../../../shared/time/getUserTimeFormat';
interface Props {
@@ -36,6 +40,9 @@ const getAfterDateByPeriod = (period: 'today' | '7d' | '30d' | 'all'): Date => {
};
export const HealthckeckAttemptsComponent = ({ database }: Props) => {
const [isHealthcheckConfigLoading, setIsHealthcheckConfigLoading] = useState(false);
const [isShowHealthcheckConfig, setIsShowHealthcheckConfig] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [healthcheckAttempts, setHealthcheckAttempts] = useState<HealthcheckAttempt[]>([]);
const [period, setPeriod] = useState<'today' | '7d' | '30d' | 'all'>('today');
@@ -71,21 +78,45 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
};
useEffect(() => {
loadHealthcheckAttempts();
}, [database, period]);
let isHealthcheckEnabled = false;
setIsHealthcheckConfigLoading(true);
healthcheckConfigApi.getHealthcheckConfig(database.id).then((healthcheckConfig) => {
setIsHealthcheckConfigLoading(false);
if (healthcheckConfig.isHealthcheckEnabled) {
isHealthcheckEnabled = true;
setIsShowHealthcheckConfig(true);
loadHealthcheckAttempts();
}
});
useEffect(() => {
if (period === 'today') {
const interval = setInterval(() => {
loadHealthcheckAttempts(false);
}, 60_000); // 1 minute
if (isHealthcheckEnabled) {
const interval = setInterval(() => {
loadHealthcheckAttempts(false);
}, 60_000); // 1 minute
return () => clearInterval(interval);
return () => clearInterval(interval);
}
}
}, [period]);
if (isHealthcheckConfigLoading) {
return (
<div className="mb-5 flex items-center">
<Spin />
</div>
);
}
if (!isShowHealthcheckConfig) {
return <div />;
}
return (
<div>
<div className="mt-5 w-full rounded bg-white p-5 shadow">
<h2 className="text-xl font-bold">Healthcheck attempts</h2>
<div className="mt-4 flex items-center gap-2">
@@ -111,7 +142,7 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
<Spin size="small" />
</div>
) : (
<div className="flex max-w-[750px] flex-wrap gap-1">
<div className="flex flex-wrap gap-1">
{healthcheckAttempts.length > 0 ? (
healthcheckAttempts.map((healthcheckAttempt) => (
<Tooltip
@@ -128,7 +159,7 @@ export const HealthckeckAttemptsComponent = ({ database }: Props) => {
</Tooltip>
))
) : (
<div className="text-xs text-gray-400">No data</div>
<div className="text-xs text-gray-400">No data yet</div>
)}
</div>
)}

View File

@@ -108,6 +108,7 @@ export const RestoresComponent = ({ database, backup }: Props) => {
restore(database);
}}
isShowDbVersionHint={false}
isBlockDbName
/>
</>
);

View File

@@ -3,7 +3,7 @@ import { Button, Input, Spin } from 'antd';
import { useState } from 'react';
import { useEffect } from 'react';
import { databaseApi } from '../../entity/databases';
import { backupConfigApi } from '../../entity/backups';
import { storageApi } from '../../entity/storages';
import type { Storage } from '../../entity/storages';
import { ToastHelper } from '../../shared/toast';
@@ -63,7 +63,7 @@ export const StorageComponent = ({ storageId, onStorageChanged, onStorageDeleted
setIsRemoving(true);
try {
const isStorageUsing = await databaseApi.isStorageUsing(storage.id);
const isStorageUsing = await backupConfigApi.isStorageUsing(storage.id);
if (isStorageUsing) {
alert('Storage is used by some databases. Please remove the storage from databases first.');
setIsShowRemoveConfirm(false);
@@ -260,7 +260,7 @@ export const StorageComponent = ({ storageId, onStorageChanged, onStorageDeleted
<ConfirmationComponent
onConfirm={remove}
onDecline={() => setIsShowRemoveConfirm(false)}
description="Are you sure you want to remove this storage? This action cannot be undone."
description="Are you sure you want to remove this storage? This action cannot be undone. If some backups are using this storage, they will be removed too."
actionText="Remove"
actionButtonColor="red"
/>