mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
FEATURE (backups): Add filters to backups panel
This commit is contained in:
@@ -40,12 +40,15 @@ func (c *BackupController) RegisterPublicRoutes(router *gin.RouterGroup) {
|
||||
|
||||
// GetBackups
|
||||
// @Summary Get backups for a database
|
||||
// @Description Get paginated backups for the specified database
|
||||
// @Description Get paginated backups for the specified database with optional filters
|
||||
// @Tags backups
|
||||
// @Produce json
|
||||
// @Param database_id query string true "Database ID"
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param offset query int false "Offset for pagination" default(0)
|
||||
// @Param status query []string false "Filter by backup status (can be repeated)" Enums(IN_PROGRESS, COMPLETED, FAILED, CANCELED)
|
||||
// @Param beforeDate query string false "Filter backups created before this date (RFC3339)" format(date-time)
|
||||
// @Param pgWalBackupType query string false "Filter by WAL backup type" Enums(PG_FULL_BACKUP, PG_WAL_SEGMENT)
|
||||
// @Success 200 {object} backups_dto.GetBackupsResponse
|
||||
// @Failure 400
|
||||
// @Failure 401
|
||||
@@ -70,7 +73,9 @@ func (c *BackupController) GetBackups(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response, err := c.backupService.GetBackups(user, databaseID, request.Limit, request.Offset)
|
||||
filters := c.buildBackupFilters(&request)
|
||||
|
||||
response, err := c.backupService.GetBackups(user, databaseID, request.Limit, request.Offset, filters)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -359,3 +364,35 @@ func (c *BackupController) startDownloadHeartbeat(ctx context.Context, userID uu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BackupController) buildBackupFilters(
|
||||
request *backups_dto.GetBackupsRequest,
|
||||
) *backups_core.BackupFilters {
|
||||
isHasFilters := len(request.Statuses) > 0 ||
|
||||
request.BeforeDate != nil ||
|
||||
request.PgWalBackupType != nil
|
||||
|
||||
if !isHasFilters {
|
||||
return nil
|
||||
}
|
||||
|
||||
filters := &backups_core.BackupFilters{}
|
||||
|
||||
if len(request.Statuses) > 0 {
|
||||
statuses := make([]backups_core.BackupStatus, 0, len(request.Statuses))
|
||||
for _, statusStr := range request.Statuses {
|
||||
statuses = append(statuses, backups_core.BackupStatus(statusStr))
|
||||
}
|
||||
|
||||
filters.Statuses = statuses
|
||||
}
|
||||
|
||||
filters.BeforeDate = request.BeforeDate
|
||||
|
||||
if request.PgWalBackupType != nil {
|
||||
walType := backups_core.PgWalBackupType(*request.PgWalBackupType)
|
||||
filters.PgWalBackupType = &walType
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
@@ -140,6 +140,225 @@ func Test_GetBackups_PermissionsEnforced(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_GetBackups_WithStatusFilter_ReturnsFilteredBackups(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database := createTestDatabase("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
CreateTestBackupWithOptions(database.ID, storage.ID, TestBackupOptions{
|
||||
Status: backups_core.BackupStatusCompleted,
|
||||
CreatedAt: now.Add(-3 * time.Hour),
|
||||
})
|
||||
CreateTestBackupWithOptions(database.ID, storage.ID, TestBackupOptions{
|
||||
Status: backups_core.BackupStatusFailed,
|
||||
CreatedAt: now.Add(-2 * time.Hour),
|
||||
})
|
||||
CreateTestBackupWithOptions(database.ID, storage.ID, TestBackupOptions{
|
||||
Status: backups_core.BackupStatusCanceled,
|
||||
CreatedAt: now.Add(-1 * time.Hour),
|
||||
})
|
||||
|
||||
// Single status filter
|
||||
var singleResponse backups_dto.GetBackupsResponse
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf("/api/v1/backups?database_id=%s&status=COMPLETED", database.ID.String()),
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusOK,
|
||||
&singleResponse,
|
||||
)
|
||||
|
||||
assert.Equal(t, int64(1), singleResponse.Total)
|
||||
assert.Len(t, singleResponse.Backups, 1)
|
||||
assert.Equal(t, backups_core.BackupStatusCompleted, singleResponse.Backups[0].Status)
|
||||
|
||||
// Multiple status filter
|
||||
var multiResponse backups_dto.GetBackupsResponse
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf(
|
||||
"/api/v1/backups?database_id=%s&status=COMPLETED&status=FAILED",
|
||||
database.ID.String(),
|
||||
),
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusOK,
|
||||
&multiResponse,
|
||||
)
|
||||
|
||||
assert.Equal(t, int64(2), multiResponse.Total)
|
||||
assert.Len(t, multiResponse.Backups, 2)
|
||||
|
||||
for _, backup := range multiResponse.Backups {
|
||||
assert.True(
|
||||
t,
|
||||
backup.Status == backups_core.BackupStatusCompleted ||
|
||||
backup.Status == backups_core.BackupStatusFailed,
|
||||
"expected COMPLETED or FAILED, got %s", backup.Status,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_GetBackups_WithBeforeDateFilter_ReturnsFilteredBackups(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database := createTestDatabase("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.Add(-1 * time.Hour)
|
||||
|
||||
olderBackup := CreateTestBackupWithOptions(database.ID, storage.ID, TestBackupOptions{
|
||||
Status: backups_core.BackupStatusCompleted,
|
||||
CreatedAt: now.Add(-3 * time.Hour),
|
||||
})
|
||||
CreateTestBackupWithOptions(database.ID, storage.ID, TestBackupOptions{
|
||||
Status: backups_core.BackupStatusCompleted,
|
||||
CreatedAt: now,
|
||||
})
|
||||
|
||||
var response backups_dto.GetBackupsResponse
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf(
|
||||
"/api/v1/backups?database_id=%s&beforeDate=%s",
|
||||
database.ID.String(),
|
||||
cutoff.Format(time.RFC3339),
|
||||
),
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusOK,
|
||||
&response,
|
||||
)
|
||||
|
||||
assert.Equal(t, int64(1), response.Total)
|
||||
assert.Len(t, response.Backups, 1)
|
||||
assert.Equal(t, olderBackup.ID, response.Backups[0].ID)
|
||||
}
|
||||
|
||||
func Test_GetBackups_WithPgWalBackupTypeFilter_ReturnsFilteredBackups(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database := createTestDatabase("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
now := time.Now().UTC()
|
||||
fullBackupType := backups_core.PgWalBackupTypeFullBackup
|
||||
walSegmentType := backups_core.PgWalBackupTypeWalSegment
|
||||
|
||||
fullBackup := CreateTestBackupWithOptions(database.ID, storage.ID, TestBackupOptions{
|
||||
Status: backups_core.BackupStatusCompleted,
|
||||
CreatedAt: now.Add(-2 * time.Hour),
|
||||
PgWalBackupType: &fullBackupType,
|
||||
})
|
||||
CreateTestBackupWithOptions(database.ID, storage.ID, TestBackupOptions{
|
||||
Status: backups_core.BackupStatusCompleted,
|
||||
CreatedAt: now.Add(-1 * time.Hour),
|
||||
PgWalBackupType: &walSegmentType,
|
||||
})
|
||||
|
||||
var response backups_dto.GetBackupsResponse
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf(
|
||||
"/api/v1/backups?database_id=%s&pgWalBackupType=PG_FULL_BACKUP",
|
||||
database.ID.String(),
|
||||
),
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusOK,
|
||||
&response,
|
||||
)
|
||||
|
||||
assert.Equal(t, int64(1), response.Total)
|
||||
assert.Len(t, response.Backups, 1)
|
||||
assert.Equal(t, fullBackup.ID, response.Backups[0].ID)
|
||||
}
|
||||
|
||||
func Test_GetBackups_WithCombinedFilters_ReturnsFilteredBackups(t *testing.T) {
|
||||
router := createTestRouter()
|
||||
owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
|
||||
workspace := workspaces_testing.CreateTestWorkspace("Test Workspace", owner, router)
|
||||
|
||||
database := createTestDatabase("Test Database", workspace.ID, owner.Token, router)
|
||||
storage := createTestStorage(workspace.ID)
|
||||
|
||||
defer func() {
|
||||
databases.RemoveTestDatabase(database)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
storages.RemoveTestStorage(storage.ID)
|
||||
workspaces_testing.RemoveTestWorkspace(workspace, router)
|
||||
}()
|
||||
|
||||
now := time.Now().UTC()
|
||||
cutoff := now.Add(-1 * time.Hour)
|
||||
|
||||
// Old completed — should match
|
||||
oldCompleted := CreateTestBackupWithOptions(database.ID, storage.ID, TestBackupOptions{
|
||||
Status: backups_core.BackupStatusCompleted,
|
||||
CreatedAt: now.Add(-3 * time.Hour),
|
||||
})
|
||||
// Old failed — should NOT match (wrong status)
|
||||
CreateTestBackupWithOptions(database.ID, storage.ID, TestBackupOptions{
|
||||
Status: backups_core.BackupStatusFailed,
|
||||
CreatedAt: now.Add(-2 * time.Hour),
|
||||
})
|
||||
// New completed — should NOT match (too recent)
|
||||
CreateTestBackupWithOptions(database.ID, storage.ID, TestBackupOptions{
|
||||
Status: backups_core.BackupStatusCompleted,
|
||||
CreatedAt: now,
|
||||
})
|
||||
|
||||
var response backups_dto.GetBackupsResponse
|
||||
test_utils.MakeGetRequestAndUnmarshal(
|
||||
t,
|
||||
router,
|
||||
fmt.Sprintf(
|
||||
"/api/v1/backups?database_id=%s&status=COMPLETED&beforeDate=%s",
|
||||
database.ID.String(),
|
||||
cutoff.Format(time.RFC3339),
|
||||
),
|
||||
"Bearer "+owner.Token,
|
||||
http.StatusOK,
|
||||
&response,
|
||||
)
|
||||
|
||||
assert.Equal(t, int64(1), response.Total)
|
||||
assert.Len(t, response.Backups, 1)
|
||||
assert.Equal(t, oldCompleted.ID, response.Backups[0].ID)
|
||||
}
|
||||
|
||||
func Test_CreateBackup_PermissionsEnforced(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -376,7 +595,7 @@ func Test_DeleteBackup_PermissionsEnforced(t *testing.T) {
|
||||
ownerUser, err := userService.GetUserFromToken(owner.Token)
|
||||
assert.NoError(t, err)
|
||||
|
||||
response, err := backups_services.GetBackupService().GetBackups(ownerUser, database.ID, 10, 0)
|
||||
response, err := backups_services.GetBackupService().GetBackups(ownerUser, database.ID, 10, 0, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(response.Backups))
|
||||
}
|
||||
|
||||
@@ -95,3 +95,33 @@ func CreateTestBackup(databaseID, storageID uuid.UUID) *backups_core.Backup {
|
||||
|
||||
return backup
|
||||
}
|
||||
|
||||
type TestBackupOptions struct {
|
||||
Status backups_core.BackupStatus
|
||||
CreatedAt time.Time
|
||||
PgWalBackupType *backups_core.PgWalBackupType
|
||||
}
|
||||
|
||||
// CreateTestBackupWithOptions creates a test backup with custom status, time, and WAL type
|
||||
func CreateTestBackupWithOptions(
|
||||
databaseID, storageID uuid.UUID,
|
||||
opts TestBackupOptions,
|
||||
) *backups_core.Backup {
|
||||
backup := &backups_core.Backup{
|
||||
ID: uuid.New(),
|
||||
DatabaseID: databaseID,
|
||||
StorageID: storageID,
|
||||
Status: opts.Status,
|
||||
BackupSizeMb: 10.5,
|
||||
BackupDurationMs: 1000,
|
||||
PgWalBackupType: opts.PgWalBackupType,
|
||||
CreatedAt: opts.CreatedAt,
|
||||
}
|
||||
|
||||
repo := &backups_core.BackupRepository{}
|
||||
if err := repo.Save(backup); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return backup
|
||||
}
|
||||
|
||||
9
backend/internal/features/backups/backups/core/dto.go
Normal file
9
backend/internal/features/backups/backups/core/dto.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package backups_core
|
||||
|
||||
import "time"
|
||||
|
||||
type BackupFilters struct {
|
||||
Statuses []BackupStatus
|
||||
BeforeDate *time.Time
|
||||
PgWalBackupType *PgWalBackupType
|
||||
}
|
||||
@@ -422,3 +422,67 @@ func (r *BackupRepository) FindLastWalSegmentAfter(
|
||||
|
||||
return &backup, nil
|
||||
}
|
||||
|
||||
func (r *BackupRepository) FindByDatabaseIDWithFiltersAndPagination(
|
||||
databaseID uuid.UUID,
|
||||
filters *BackupFilters,
|
||||
limit, offset int,
|
||||
) ([]*Backup, error) {
|
||||
var backups []*Backup
|
||||
|
||||
query := storage.
|
||||
GetDb().
|
||||
Where("database_id = ?", databaseID)
|
||||
|
||||
if filters != nil {
|
||||
query = filters.applyToQuery(query)
|
||||
}
|
||||
|
||||
if err := query.
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&backups).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
func (r *BackupRepository) CountByDatabaseIDWithFilters(
|
||||
databaseID uuid.UUID,
|
||||
filters *BackupFilters,
|
||||
) (int64, error) {
|
||||
var count int64
|
||||
|
||||
query := storage.
|
||||
GetDb().
|
||||
Model(&Backup{}).
|
||||
Where("database_id = ?", databaseID)
|
||||
|
||||
if filters != nil {
|
||||
query = filters.applyToQuery(query)
|
||||
}
|
||||
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (f *BackupFilters) applyToQuery(query *gorm.DB) *gorm.DB {
|
||||
if len(f.Statuses) > 0 {
|
||||
query = query.Where("status IN ?", f.Statuses)
|
||||
}
|
||||
|
||||
if f.BeforeDate != nil {
|
||||
query = query.Where("created_at < ?", *f.BeforeDate)
|
||||
}
|
||||
|
||||
if f.PgWalBackupType != nil {
|
||||
query = query.Where("pg_wal_backup_type = ?", *f.PgWalBackupType)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
@@ -11,9 +11,12 @@ import (
|
||||
)
|
||||
|
||||
type GetBackupsRequest struct {
|
||||
DatabaseID string `form:"database_id" binding:"required"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
DatabaseID string `form:"database_id" binding:"required"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Statuses []string `form:"status"`
|
||||
BeforeDate *time.Time `form:"beforeDate"`
|
||||
PgWalBackupType *string `form:"pgWalBackupType"`
|
||||
}
|
||||
|
||||
type GetBackupsResponse struct {
|
||||
|
||||
@@ -109,6 +109,7 @@ func (s *BackupService) GetBackups(
|
||||
user *users_models.User,
|
||||
databaseID uuid.UUID,
|
||||
limit, offset int,
|
||||
filters *backups_core.BackupFilters,
|
||||
) (*backups_dto.GetBackupsResponse, error) {
|
||||
database, err := s.databaseService.GetDatabaseByID(databaseID)
|
||||
if err != nil {
|
||||
@@ -134,12 +135,14 @@ func (s *BackupService) GetBackups(
|
||||
offset = 0
|
||||
}
|
||||
|
||||
backups, err := s.backupRepository.FindByDatabaseIDWithPagination(databaseID, limit, offset)
|
||||
backups, err := s.backupRepository.FindByDatabaseIDWithFiltersAndPagination(
|
||||
databaseID, filters, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total, err := s.backupRepository.CountByDatabaseID(databaseID)
|
||||
total, err := s.backupRepository.CountByDatabaseIDWithFilters(databaseID, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func (c *VersionController) RegisterRoutes(router *gin.RouterGroup) {
|
||||
func (c *VersionController) GetVersion(ctx *gin.Context) {
|
||||
version := os.Getenv("APP_VERSION")
|
||||
if version == "" {
|
||||
version = "dev"
|
||||
version = "3.26.0"
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, VersionResponse{Version: version})
|
||||
|
||||
Reference in New Issue
Block a user