Skip to content

Commit 97d7253

Browse files
FEATURE (databases): Add DB copying
1 parent 81aadd1 commit 97d7253

File tree

9 files changed

+186
-44
lines changed

9 files changed

+186
-44
lines changed

backend/internal/features/backups/backups/di.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func SetupDependencies() {
4444
SetDatabaseStorageChangeListener(backupService)
4545

4646
databases.GetDatabaseService().AddDbRemoveListener(backupService)
47+
databases.GetDatabaseService().AddDbCopyListener(backups_config.GetBackupConfigService())
4748
}
4849

4950
func GetBackupService() *BackupService {

backend/internal/features/backups/config/service.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,27 @@ func storageIDsEqual(id1, id2 *uuid.UUID) bool {
164164
}
165165
return *id1 == *id2
166166
}
167+
168+
func (s *BackupConfigService) OnDatabaseCopied(originalDatabaseID, newDatabaseID uuid.UUID) {
169+
originalConfig, err := s.GetBackupConfigByDbId(originalDatabaseID)
170+
if err != nil {
171+
return
172+
}
173+
174+
newConfig := &BackupConfig{
175+
DatabaseID: newDatabaseID,
176+
IsBackupsEnabled: originalConfig.IsBackupsEnabled,
177+
StorePeriod: originalConfig.StorePeriod,
178+
BackupIntervalID: originalConfig.BackupIntervalID,
179+
StorageID: originalConfig.StorageID,
180+
SendNotificationsOn: originalConfig.SendNotificationsOn,
181+
IsRetryIfFailed: originalConfig.IsRetryIfFailed,
182+
MaxFailedTriesCount: originalConfig.MaxFailedTriesCount,
183+
CpuCount: originalConfig.CpuCount,
184+
}
185+
186+
_, err = s.SaveBackupConfig(newConfig)
187+
if err != nil {
188+
return
189+
}
190+
}

backend/internal/features/databases/controller.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func (c *DatabaseController) RegisterRoutes(router *gin.RouterGroup) {
2121
router.GET("/databases", c.GetDatabases)
2222
router.POST("/databases/:id/test-connection", c.TestDatabaseConnection)
2323
router.POST("/databases/test-connection-direct", c.TestDatabaseConnectionDirect)
24+
router.POST("/databases/:id/copy", c.CopyDatabase)
2425
router.GET("/databases/notifier/:id/is-using", c.IsNotifierUsing)
2526

2627
}
@@ -325,3 +326,42 @@ func (c *DatabaseController) IsNotifierUsing(ctx *gin.Context) {
325326

326327
ctx.JSON(http.StatusOK, gin.H{"isUsing": isUsing})
327328
}
329+
330+
// CopyDatabase
331+
// @Summary Copy a database
332+
// @Description Copy an existing database configuration
333+
// @Tags databases
334+
// @Produce json
335+
// @Param id path string true "Database ID"
336+
// @Success 201 {object} Database
337+
// @Failure 400
338+
// @Failure 401
339+
// @Failure 500
340+
// @Router /databases/{id}/copy [post]
341+
func (c *DatabaseController) CopyDatabase(ctx *gin.Context) {
342+
id, err := uuid.Parse(ctx.Param("id"))
343+
if err != nil {
344+
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid database ID"})
345+
return
346+
}
347+
348+
authorizationHeader := ctx.GetHeader("Authorization")
349+
if authorizationHeader == "" {
350+
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
351+
return
352+
}
353+
354+
user, err := c.userService.GetUserFromToken(authorizationHeader)
355+
if err != nil {
356+
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
357+
return
358+
}
359+
360+
copiedDatabase, err := c.databaseService.CopyDatabase(user, id)
361+
if err != nil {
362+
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
363+
return
364+
}
365+
366+
ctx.JSON(http.StatusCreated, copiedDatabase)
367+
}

backend/internal/features/databases/di.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ var databaseService = &DatabaseService{
1414
logger.GetLogger(),
1515
[]DatabaseCreationListener{},
1616
[]DatabaseRemoveListener{},
17+
[]DatabaseCopyListener{},
1718
}
1819

1920
var databaseController = &DatabaseController{

backend/internal/features/databases/interfaces.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,7 @@ type DatabaseCreationListener interface {
2121
type DatabaseRemoveListener interface {
2222
OnBeforeDatabaseRemove(databaseID uuid.UUID) error
2323
}
24+
25+
type DatabaseCopyListener interface {
26+
OnDatabaseCopied(originalDatabaseID, newDatabaseID uuid.UUID)
27+
}

backend/internal/features/databases/model.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ func (d *Database) Validate() error {
3535

3636
switch d.Type {
3737
case DatabaseTypePostgres:
38+
if d.Postgresql == nil {
39+
return errors.New("postgresql database is required")
40+
}
41+
3842
return d.Postgresql.Validate()
3943
default:
4044
return errors.New("invalid database type: " + string(d.Type))

backend/internal/features/databases/service.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package databases
33
import (
44
"errors"
55
"log/slog"
6+
"postgresus-backend/internal/features/databases/databases/postgresql"
67
"postgresus-backend/internal/features/notifiers"
78
users_models "postgresus-backend/internal/features/users/models"
89
"time"
@@ -17,6 +18,7 @@ type DatabaseService struct {
1718

1819
dbCreationListener []DatabaseCreationListener
1920
dbRemoveListener []DatabaseRemoveListener
21+
dbCopyListener []DatabaseCopyListener
2022
}
2123

2224
func (s *DatabaseService) AddDbCreationListener(
@@ -31,6 +33,12 @@ func (s *DatabaseService) AddDbRemoveListener(
3133
s.dbRemoveListener = append(s.dbRemoveListener, dbRemoveListener)
3234
}
3335

36+
func (s *DatabaseService) AddDbCopyListener(
37+
dbCopyListener DatabaseCopyListener,
38+
) {
39+
s.dbCopyListener = append(s.dbCopyListener, dbCopyListener)
40+
}
41+
3442
func (s *DatabaseService) CreateDatabase(
3543
user *users_models.User,
3644
database *Database,
@@ -220,6 +228,67 @@ func (s *DatabaseService) SetLastBackupTime(databaseID uuid.UUID, backupTime tim
220228
return nil
221229
}
222230

231+
func (s *DatabaseService) CopyDatabase(
232+
user *users_models.User,
233+
databaseID uuid.UUID,
234+
) (*Database, error) {
235+
existingDatabase, err := s.dbRepository.FindByID(databaseID)
236+
if err != nil {
237+
return nil, err
238+
}
239+
240+
if existingDatabase.UserID != user.ID {
241+
return nil, errors.New("you have not access to this database")
242+
}
243+
244+
newDatabase := &Database{
245+
ID: uuid.Nil,
246+
UserID: user.ID,
247+
Name: existingDatabase.Name + " (Copy)",
248+
Type: existingDatabase.Type,
249+
Notifiers: existingDatabase.Notifiers,
250+
LastBackupTime: nil,
251+
LastBackupErrorMessage: nil,
252+
HealthStatus: existingDatabase.HealthStatus,
253+
}
254+
255+
switch existingDatabase.Type {
256+
case DatabaseTypePostgres:
257+
if existingDatabase.Postgresql != nil {
258+
newDatabase.Postgresql = &postgresql.PostgresqlDatabase{
259+
ID: uuid.Nil,
260+
DatabaseID: nil,
261+
Version: existingDatabase.Postgresql.Version,
262+
Host: existingDatabase.Postgresql.Host,
263+
Port: existingDatabase.Postgresql.Port,
264+
Username: existingDatabase.Postgresql.Username,
265+
Password: existingDatabase.Postgresql.Password,
266+
Database: existingDatabase.Postgresql.Database,
267+
IsHttps: existingDatabase.Postgresql.IsHttps,
268+
}
269+
}
270+
}
271+
272+
if err := newDatabase.Validate(); err != nil {
273+
return nil, err
274+
}
275+
276+
copiedDatabase, err := s.dbRepository.Save(newDatabase)
277+
if err != nil {
278+
return nil, err
279+
}
280+
281+
for _, listener := range s.dbCreationListener {
282+
listener.OnDatabaseCreated(copiedDatabase.ID)
283+
}
284+
285+
for _, listener := range s.dbCopyListener {
286+
listener.OnDatabaseCopied(databaseID, copiedDatabase.ID)
287+
}
288+
289+
return copiedDatabase, nil
290+
}
291+
223292
func (s *DatabaseService) SetHealthStatus(
224293
databaseID uuid.UUID,
225294
healthStatus *HealthStatus,

frontend/src/entity/databases/api/databaseApi.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ export const databaseApi = {
4848
);
4949
},
5050

51+
async copyDatabase(id: string) {
52+
const requestOptions: RequestOptions = new RequestOptions();
53+
return apiHelper.fetchPostJson<Database>(
54+
`${getApplicationServer()}/api/v1/databases/${id}/copy`,
55+
requestOptions,
56+
);
57+
},
58+
5159
async testDatabaseConnection(id: string) {
5260
const requestOptions: RequestOptions = new RequestOptions();
5361
return apiHelper.fetchPostJson(

frontend/src/features/databases/ui/DatabaseConfigComponent.tsx

Lines changed: 35 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ import { ToastHelper } from '../../../shared/toast';
77
import { ConfirmationComponent } from '../../../shared/ui';
88
import { EditBackupConfigComponent, ShowBackupConfigComponent } from '../../backups';
99
import { EditHealthcheckConfigComponent, ShowHealthcheckConfigComponent } from '../../healthcheck';
10-
import {
11-
EditMonitoringSettingsComponent,
12-
ShowMonitoringSettingsComponent,
13-
} from '../../monitoring/settings';
1410
import { EditDatabaseNotifiersComponent } from './edit/EditDatabaseNotifiersComponent';
1511
import { EditDatabaseSpecificDataComponent } from './edit/EditDatabaseSpecificDataComponent';
1612
import { ShowDatabaseNotifiersComponent } from './show/ShowDatabaseNotifiersComponent';
@@ -39,13 +35,12 @@ export const DatabaseConfigComponent = ({
3935
const [isEditBackupConfig, setIsEditBackupConfig] = useState(false);
4036
const [isEditNotifiersSettings, setIsEditNotifiersSettings] = useState(false);
4137
const [isEditHealthcheckSettings, setIsEditHealthcheckSettings] = useState(false);
42-
const [isEditMonitoringSettings, setIsEditMonitoringSettings] = useState(false);
4338

4439
const [isNameUnsaved, setIsNameUnsaved] = useState(false);
4540
const [isSaving, setIsSaving] = useState(false);
4641

4742
const [isTestingConnection, setIsTestingConnection] = useState(false);
48-
43+
const [isCopying, setIsCopying] = useState(false);
4944
const [isShowRemoveConfirm, setIsShowRemoveConfirm] = useState(false);
5045
const [isRemoving, setIsRemoving] = useState(false);
5146

@@ -55,6 +50,28 @@ export const DatabaseConfigComponent = ({
5550
databaseApi.getDatabase(database.id).then(setDatabase);
5651
};
5752

53+
const copyDatabase = () => {
54+
if (!database) return;
55+
56+
setIsCopying(true);
57+
58+
databaseApi
59+
.copyDatabase(database.id)
60+
.then((copiedDatabase) => {
61+
ToastHelper.showToast({
62+
title: 'Database copied successfully!',
63+
description: `"${copiedDatabase.name}" has been created successfully`,
64+
});
65+
window.location.reload();
66+
})
67+
.catch((e: Error) => {
68+
alert(e.message);
69+
})
70+
.finally(() => {
71+
setIsCopying(false);
72+
});
73+
};
74+
5875
const testConnection = () => {
5976
if (!database) return;
6077

@@ -97,16 +114,13 @@ export const DatabaseConfigComponent = ({
97114
});
98115
};
99116

100-
const startEdit = (
101-
type: 'name' | 'database' | 'backup-config' | 'notifiers' | 'healthcheck' | 'monitoring',
102-
) => {
117+
const startEdit = (type: 'name' | 'database' | 'backup-config' | 'notifiers' | 'healthcheck') => {
103118
setEditDatabase(JSON.parse(JSON.stringify(database)));
104119
setIsEditName(type === 'name');
105120
setIsEditDatabaseSpecificDataSettings(type === 'database');
106121
setIsEditBackupConfig(type === 'backup-config');
107122
setIsEditNotifiersSettings(type === 'notifiers');
108123
setIsEditHealthcheckSettings(type === 'healthcheck');
109-
setIsEditMonitoringSettings(type === 'monitoring');
110124
setIsNameUnsaved(false);
111125
};
112126

@@ -344,40 +358,6 @@ export const DatabaseConfigComponent = ({
344358
</div>
345359
</div>
346360

347-
<div className="flex flex-wrap gap-10">
348-
<div className="w-[400px]">
349-
<div className="mt-5 flex items-center font-bold">
350-
<div>Monitoring settings</div>
351-
352-
{!isEditMonitoringSettings ? (
353-
<div className="ml-2 h-4 w-4 cursor-pointer" onClick={() => startEdit('monitoring')}>
354-
<img src="/icons/pen-gray.svg" />
355-
</div>
356-
) : (
357-
<div />
358-
)}
359-
</div>
360-
361-
<div className="mt-1 text-sm">
362-
{isEditMonitoringSettings ? (
363-
<EditMonitoringSettingsComponent
364-
database={database}
365-
onCancel={() => {
366-
setIsEditMonitoringSettings(false);
367-
loadSettings();
368-
}}
369-
onSaved={() => {
370-
setIsEditMonitoringSettings(false);
371-
loadSettings();
372-
}}
373-
/>
374-
) : (
375-
<ShowMonitoringSettingsComponent database={database} />
376-
)}
377-
</div>
378-
</div>
379-
</div>
380-
381361
{!isEditDatabaseSpecificDataSettings && (
382362
<div className="mt-10">
383363
<Button
@@ -391,6 +371,17 @@ export const DatabaseConfigComponent = ({
391371
Test connection
392372
</Button>
393373

374+
<Button
375+
type="primary"
376+
className="mr-1"
377+
ghost
378+
onClick={copyDatabase}
379+
loading={isCopying}
380+
disabled={isCopying}
381+
>
382+
Copy
383+
</Button>
384+
394385
<Button
395386
type="primary"
396387
danger

0 commit comments

Comments
 (0)