diff --git a/backend/internal/features/backups/backups/di.go b/backend/internal/features/backups/backups/di.go index 44b0ac0..27070ca 100644 --- a/backend/internal/features/backups/backups/di.go +++ b/backend/internal/features/backups/backups/di.go @@ -44,6 +44,7 @@ func SetupDependencies() { SetDatabaseStorageChangeListener(backupService) databases.GetDatabaseService().AddDbRemoveListener(backupService) + databases.GetDatabaseService().AddDbCopyListener(backups_config.GetBackupConfigService()) } func GetBackupService() *BackupService { diff --git a/backend/internal/features/backups/config/service.go b/backend/internal/features/backups/config/service.go index beebd11..8a1f69b 100644 --- a/backend/internal/features/backups/config/service.go +++ b/backend/internal/features/backups/config/service.go @@ -164,3 +164,27 @@ func storageIDsEqual(id1, id2 *uuid.UUID) bool { } return *id1 == *id2 } + +func (s *BackupConfigService) OnDatabaseCopied(originalDatabaseID, newDatabaseID uuid.UUID) { + originalConfig, err := s.GetBackupConfigByDbId(originalDatabaseID) + if err != nil { + return + } + + newConfig := &BackupConfig{ + DatabaseID: newDatabaseID, + IsBackupsEnabled: originalConfig.IsBackupsEnabled, + StorePeriod: originalConfig.StorePeriod, + BackupIntervalID: originalConfig.BackupIntervalID, + StorageID: originalConfig.StorageID, + SendNotificationsOn: originalConfig.SendNotificationsOn, + IsRetryIfFailed: originalConfig.IsRetryIfFailed, + MaxFailedTriesCount: originalConfig.MaxFailedTriesCount, + CpuCount: originalConfig.CpuCount, + } + + _, err = s.SaveBackupConfig(newConfig) + if err != nil { + return + } +} diff --git a/backend/internal/features/databases/controller.go b/backend/internal/features/databases/controller.go index 2ed4738..3dcf3ce 100644 --- a/backend/internal/features/databases/controller.go +++ b/backend/internal/features/databases/controller.go @@ -21,6 +21,7 @@ func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) { router.GET("/databases", c.GetDatabases) router.POST("/databases/:id/test-connection", c.TestDatabaseConnection) router.POST("/databases/test-connection-direct", c.TestDatabaseConnectionDirect) + router.POST("/databases/:id/copy", c.CopyDatabase) router.GET("/databases/notifier/:id/is-using", c.IsNotifierUsing) } @@ -325,3 +326,42 @@ func (c *DatabaseController) IsNotifierUsing(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing}) } + +// CopyDatabase +// @Summary Copy a database +// @Description Copy an existing database configuration +// @Tags databases +// @Produce json +// @Param id path string true "Database ID" +// @Success 201 {object} Database +// @Failure 400 +// @Failure 401 +// @Failure 500 +// @Router /databases/{id}/copy [post] +func (c *DatabaseController) CopyDatabase(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 + } + + user, err := c.userService.GetUserFromToken(authorizationHeader) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + copiedDatabase, err := c.databaseService.CopyDatabase(user, id) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusCreated, copiedDatabase) +} diff --git a/backend/internal/features/databases/di.go b/backend/internal/features/databases/di.go index 59d067c..9a8a733 100644 --- a/backend/internal/features/databases/di.go +++ b/backend/internal/features/databases/di.go @@ -14,6 +14,7 @@ var databaseService = &DatabaseService{ logger.GetLogger(), []DatabaseCreationListener{}, []DatabaseRemoveListener{}, + []DatabaseCopyListener{}, } var databaseController = &DatabaseController{ diff --git a/backend/internal/features/databases/interfaces.go b/backend/internal/features/databases/interfaces.go index ba63d6a..f0eb7f6 100644 --- a/backend/internal/features/databases/interfaces.go +++ b/backend/internal/features/databases/interfaces.go @@ -21,3 +21,7 @@ type DatabaseCreationListener interface { type DatabaseRemoveListener interface { OnBeforeDatabaseRemove(databaseID uuid.UUID) error } + +type DatabaseCopyListener interface { + OnDatabaseCopied(originalDatabaseID, newDatabaseID uuid.UUID) +} diff --git a/backend/internal/features/databases/model.go b/backend/internal/features/databases/model.go index 89fca79..9f9acda 100644 --- a/backend/internal/features/databases/model.go +++ b/backend/internal/features/databases/model.go @@ -35,6 +35,10 @@ func (d *Database) Validate() error { switch d.Type { case DatabaseTypePostgres: + if d.Postgresql == nil { + return errors.New("postgresql database is required") + } + return d.Postgresql.Validate() default: return errors.New("invalid database type: " + string(d.Type)) diff --git a/backend/internal/features/databases/service.go b/backend/internal/features/databases/service.go index c069670..52090e7 100644 --- a/backend/internal/features/databases/service.go +++ b/backend/internal/features/databases/service.go @@ -3,6 +3,7 @@ package databases import ( "errors" "log/slog" + "postgresus-backend/internal/features/databases/databases/postgresql" "postgresus-backend/internal/features/notifiers" users_models "postgresus-backend/internal/features/users/models" "time" @@ -17,6 +18,7 @@ type DatabaseService struct { dbCreationListener []DatabaseCreationListener dbRemoveListener []DatabaseRemoveListener + dbCopyListener []DatabaseCopyListener } func (s *DatabaseService) AddDbCreationListener( @@ -31,6 +33,12 @@ func (s *DatabaseService) AddDbRemoveListener( s.dbRemoveListener = append(s.dbRemoveListener, dbRemoveListener) } +func (s *DatabaseService) AddDbCopyListener( + dbCopyListener DatabaseCopyListener, +) { + s.dbCopyListener = append(s.dbCopyListener, dbCopyListener) +} + func (s *DatabaseService) CreateDatabase( user *users_models.User, database *Database, @@ -220,6 +228,67 @@ func (s *DatabaseService) SetLastBackupTime(databaseID uuid.UUID, backupTime tim return nil } +func (s *DatabaseService) CopyDatabase( + user *users_models.User, + databaseID uuid.UUID, +) (*Database, error) { + existingDatabase, err := s.dbRepository.FindByID(databaseID) + if err != nil { + return nil, err + } + + if existingDatabase.UserID != user.ID { + return nil, errors.New("you have not access to this database") + } + + newDatabase := &Database{ + ID: uuid.Nil, + UserID: user.ID, + Name: existingDatabase.Name + " (Copy)", + Type: existingDatabase.Type, + Notifiers: existingDatabase.Notifiers, + LastBackupTime: nil, + LastBackupErrorMessage: nil, + HealthStatus: existingDatabase.HealthStatus, + } + + switch existingDatabase.Type { + case DatabaseTypePostgres: + if existingDatabase.Postgresql != nil { + newDatabase.Postgresql = &postgresql.PostgresqlDatabase{ + ID: uuid.Nil, + DatabaseID: nil, + Version: existingDatabase.Postgresql.Version, + Host: existingDatabase.Postgresql.Host, + Port: existingDatabase.Postgresql.Port, + Username: existingDatabase.Postgresql.Username, + Password: existingDatabase.Postgresql.Password, + Database: existingDatabase.Postgresql.Database, + IsHttps: existingDatabase.Postgresql.IsHttps, + } + } + } + + if err := newDatabase.Validate(); err != nil { + return nil, err + } + + copiedDatabase, err := s.dbRepository.Save(newDatabase) + if err != nil { + return nil, err + } + + for _, listener := range s.dbCreationListener { + listener.OnDatabaseCreated(copiedDatabase.ID) + } + + for _, listener := range s.dbCopyListener { + listener.OnDatabaseCopied(databaseID, copiedDatabase.ID) + } + + return copiedDatabase, nil +} + func (s *DatabaseService) SetHealthStatus( databaseID uuid.UUID, healthStatus *HealthStatus, diff --git a/frontend/src/entity/databases/api/databaseApi.ts b/frontend/src/entity/databases/api/databaseApi.ts index 1842277..0f275b7 100644 --- a/frontend/src/entity/databases/api/databaseApi.ts +++ b/frontend/src/entity/databases/api/databaseApi.ts @@ -48,6 +48,14 @@ export const databaseApi = { ); }, + async copyDatabase(id: string) { + const requestOptions: RequestOptions = new RequestOptions(); + return apiHelper.fetchPostJson( + `${getApplicationServer()}/api/v1/databases/${id}/copy`, + requestOptions, + ); + }, + async testDatabaseConnection(id: string) { const requestOptions: RequestOptions = new RequestOptions(); return apiHelper.fetchPostJson( diff --git a/frontend/src/features/databases/ui/DatabaseConfigComponent.tsx b/frontend/src/features/databases/ui/DatabaseConfigComponent.tsx index e691eeb..0ca28ee 100644 --- a/frontend/src/features/databases/ui/DatabaseConfigComponent.tsx +++ b/frontend/src/features/databases/ui/DatabaseConfigComponent.tsx @@ -7,10 +7,6 @@ import { ToastHelper } from '../../../shared/toast'; import { ConfirmationComponent } from '../../../shared/ui'; import { EditBackupConfigComponent, ShowBackupConfigComponent } from '../../backups'; import { EditHealthcheckConfigComponent, ShowHealthcheckConfigComponent } from '../../healthcheck'; -import { - EditMonitoringSettingsComponent, - ShowMonitoringSettingsComponent, -} from '../../monitoring/settings'; import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent'; import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent'; import { ShowDatabaseNotifiersComponent } from './show/ShowDatabaseNotifiersComponent'; @@ -39,13 +35,12 @@ export const DatabaseConfigComponent = ({ const [isEditBackupConfig, setIsEditBackupConfig] = useState(false); const [isEditNotifiersSettings, setIsEditNotifiersSettings] = useState(false); const [isEditHealthcheckSettings, setIsEditHealthcheckSettings] = useState(false); - const [isEditMonitoringSettings, setIsEditMonitoringSettings] = useState(false); const [isNameUnsaved, setIsNameUnsaved] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isTestingConnection, setIsTestingConnection] = useState(false); - + const [isCopying, setIsCopying] = useState(false); const [isShowRemoveConfirm, setIsShowRemoveConfirm] = useState(false); const [isRemoving, setIsRemoving] = useState(false); @@ -55,6 +50,28 @@ export const DatabaseConfigComponent = ({ databaseApi.getDatabase(database.id).then(setDatabase); }; + const copyDatabase = () => { + if (!database) return; + + setIsCopying(true); + + databaseApi + .copyDatabase(database.id) + .then((copiedDatabase) => { + ToastHelper.showToast({ + title: 'Database copied successfully!', + description: `"${copiedDatabase.name}" has been created successfully`, + }); + window.location.reload(); + }) + .catch((e: Error) => { + alert(e.message); + }) + .finally(() => { + setIsCopying(false); + }); + }; + const testConnection = () => { if (!database) return; @@ -97,16 +114,13 @@ export const DatabaseConfigComponent = ({ }); }; - const startEdit = ( - type: 'name' | 'database' | 'backup-config' | 'notifiers' | 'healthcheck' | 'monitoring', - ) => { + const startEdit = (type: 'name' | 'database' | 'backup-config' | 'notifiers' | 'healthcheck') => { setEditDatabase(JSON.parse(JSON.stringify(database))); setIsEditName(type === 'name'); setIsEditDatabaseSpecificDataSettings(type === 'database'); setIsEditBackupConfig(type === 'backup-config'); setIsEditNotifiersSettings(type === 'notifiers'); setIsEditHealthcheckSettings(type === 'healthcheck'); - setIsEditMonitoringSettings(type === 'monitoring'); setIsNameUnsaved(false); }; @@ -344,40 +358,6 @@ export const DatabaseConfigComponent = ({ -
-
-
-
Monitoring settings
- - {!isEditMonitoringSettings ? ( -
startEdit('monitoring')}> - -
- ) : ( -
- )} -
- -
- {isEditMonitoringSettings ? ( - { - setIsEditMonitoringSettings(false); - loadSettings(); - }} - onSaved={() => { - setIsEditMonitoringSettings(false); - loadSettings(); - }} - /> - ) : ( - - )} -
-
-
- {!isEditDatabaseSpecificDataSettings && (
+