mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
feat(backups): scheduled database backups via cron tick
- database_services gains backup_schedule + next_backup_at (goose migration, sqlc schema, new queries). - PUT /databases/:id accepts backup_schedule — validated cron expression, empty string disables; invalid returns 400. - Cron scheduler tick now also folds due databases into the existing snapshot pipeline (createBackupProcess). - Databases page backups section gets a schedule row showing current expr and next run. - resolveBackupConfig reports real next_backup/enabled instead of a fake last+24h estimate. - Verified e2e: '* * * * *' produced three consecutive completed archives containing the seeded payload; invalid schedule rejected. Authored By: TDvorak <[email protected]>
This commit is contained in:
1 parent
aeb91b03e9
commit
08d92fb10f
13 files changed
+387
-99
No files matched your search
+6
-3
@@ -332,9 +332,12 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo.
|
|||||||
through logs was not viable). Download action in the `/databases` backups
|
through logs was not viable). Download action in the `/databases` backups
|
||||||
list. Verified e2e: byte-identical archive, 404 missing/cross-user,
|
list. Verified e2e: byte-identical archive, 404 missing/cross-user,
|
||||||
401 unauthenticated.
|
401 unauthenticated.
|
||||||
- [ ] **Scheduled database backups**: backups are manual-only despite a
|
- [x] **Scheduled database backups**: `database_services.backup_schedule`
|
||||||
`retention` field. Add a `schedule` column + fold due database backups
|
(cron expr) + `next_backup_at`; `PUT /databases/:id` accepts
|
||||||
into the cron scheduler tick.
|
`backup_schedule` (validated, empty string disables). The cron tick folds
|
||||||
|
due databases into `createBackupProcess`. Schedule row in the
|
||||||
|
`/databases` backups section. Verified e2e: `* * * * *` produced three
|
||||||
|
consecutive completed archives with real volume data.
|
||||||
|
|
||||||
**Gate**: every registered route is reachable from the UI or has an explicit
|
**Gate**: every registered route is reachable from the UI or has an explicit
|
||||||
"API-only" note in the spec. A request to `/g/{service}` with a valid key
|
"API-only" note in the spec. A request to `/g/{service}` with a valid key
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
"containr/internal/database/sqlcdb"
|
||||||
"containr/internal/docker"
|
"containr/internal/docker"
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -436,6 +438,7 @@ func StartCronScheduler(ctx context.Context, db *database.DB, dockerClient *dock
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
runDueCronJobs(db, dockerClient)
|
runDueCronJobs(db, dockerClient)
|
||||||
|
runDueDatabaseBackups(db, dockerClient)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -478,6 +481,51 @@ func runDueCronJobs(db *database.DB, dockerClient *docker.Client) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runDueDatabaseBackups snapshots managed databases whose backup_schedule is
|
||||||
|
// due, reusing the same archive pipeline as manual backups.
|
||||||
|
func runDueDatabaseBackups(db *database.DB, dockerClient *docker.Client) {
|
||||||
|
if dockerClient == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := sqlcdb.New(db.DB)
|
||||||
|
due, err := q.ListDueDatabaseBackups(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cron scheduler: failed to list due database backups: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler := NewDatabaseHandler(db.DB, dockerClient)
|
||||||
|
for _, row := range due {
|
||||||
|
schedule := row.BackupSchedule.String
|
||||||
|
next, err := calculateNextRun(schedule, "UTC")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cron scheduler: invalid backup schedule %q on %s: %v", schedule, row.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := q.SetDatabaseNextBackupAt(context.Background(), sqlcdb.SetDatabaseNextBackupAtParams{
|
||||||
|
NextBackupAt: sql.NullTime{Time: *next, Valid: true},
|
||||||
|
ID: row.ID,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("cron scheduler: failed to advance backup schedule on %s: %v", row.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
backupID := generateDatabaseBackupID(row.ID)
|
||||||
|
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
|
||||||
|
if err := q.CreateDatabaseBackup(context.Background(), sqlcdb.CreateDatabaseBackupParams{
|
||||||
|
ID: backupID,
|
||||||
|
DatabaseID: row.ID,
|
||||||
|
Size: "pending",
|
||||||
|
Status: "in_progress",
|
||||||
|
BackupPath: sql.NullString{String: archivePath, Valid: true},
|
||||||
|
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("cron scheduler: failed to record backup for %s: %v", row.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go handler.createBackupProcess(row.ID, backupID, archivePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// findServiceContainer returns the first running container whose name matches
|
// findServiceContainer returns the first running container whose name matches
|
||||||
// the deployment naming convention containr-<serviceID>-*.
|
// the deployment naming convention containr-<serviceID>-*.
|
||||||
func findServiceContainer(ctx context.Context, dockerClient *docker.Client, serviceID string) (string, error) {
|
func findServiceContainer(ctx context.Context, dockerClient *docker.Client, serviceID string) (string, error) {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ type DatabaseService struct {
|
|||||||
Version string `json:"version" db:"version"`
|
Version string `json:"version" db:"version"`
|
||||||
Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business
|
Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business
|
||||||
Region string `json:"region" db:"region"`
|
Region string `json:"region" db:"region"`
|
||||||
|
BackupSchedule string `json:"backup_schedule,omitempty"`
|
||||||
|
NextBackupAt *time.Time `json:"next_backup_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
ConnectionURL string `json:"connection_url"`
|
ConnectionURL string `json:"connection_url"`
|
||||||
@@ -102,8 +104,9 @@ type DatabaseCreateRequest struct {
|
|||||||
|
|
||||||
// DatabaseUpdateRequest represents a request to update a database
|
// DatabaseUpdateRequest represents a request to update a database
|
||||||
type DatabaseUpdateRequest struct {
|
type DatabaseUpdateRequest struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
Plan string `json:"plan,omitempty"`
|
Plan string `json:"plan,omitempty"`
|
||||||
|
BackupSchedule *string `json:"backup_schedule,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseActionRequest represents a request to perform database actions
|
// DatabaseActionRequest represents a request to perform database actions
|
||||||
@@ -183,7 +186,7 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) {
|
|||||||
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
||||||
|
|
||||||
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
|
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
|
||||||
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db.ID); err == nil {
|
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
|
||||||
db.Backups = backupConfig
|
db.Backups = backupConfig
|
||||||
} else {
|
} else {
|
||||||
db.Backups = h.generateMockBackupConfig()
|
db.Backups = h.generateMockBackupConfig()
|
||||||
@@ -220,7 +223,7 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
|
|||||||
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
|
||||||
|
|
||||||
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
|
db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db)
|
||||||
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db.ID); err == nil {
|
if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil {
|
||||||
db.Backups = backupConfig
|
db.Backups = backupConfig
|
||||||
} else {
|
} else {
|
||||||
db.Backups = h.generateMockBackupConfig()
|
db.Backups = h.generateMockBackupConfig()
|
||||||
@@ -364,40 +367,67 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
|
|||||||
req.Name = strings.TrimSpace(req.Name)
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
req.Plan = strings.TrimSpace(req.Plan)
|
req.Plan = strings.TrimSpace(req.Plan)
|
||||||
|
|
||||||
if req.Name == "" && req.Plan == "" {
|
if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
now := sql.NullTime{Time: time.Now(), Valid: true}
|
if req.BackupSchedule != nil {
|
||||||
var err error
|
var schedule sql.NullString
|
||||||
switch {
|
var nextBackup sql.NullTime
|
||||||
case req.Name != "" && req.Plan != "":
|
if expr := strings.TrimSpace(*req.BackupSchedule); expr != "" {
|
||||||
err = h.queries.UpdateDatabaseServiceNameAndPlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameAndPlanByIDAndUserParams{
|
next, err := calculateNextRun(expr, "UTC")
|
||||||
Name: req.Name,
|
if err != nil {
|
||||||
Plan: req.Plan,
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cron schedule"})
|
||||||
UpdatedAt: now,
|
return
|
||||||
ID: databaseID,
|
}
|
||||||
UserID: userID,
|
schedule = sql.NullString{String: expr, Valid: true}
|
||||||
})
|
nextBackup = sql.NullTime{Time: *next, Valid: true}
|
||||||
case req.Name != "":
|
}
|
||||||
err = h.queries.UpdateDatabaseServiceNameByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameByIDAndUserParams{
|
err := h.queries.SetDatabaseBackupScheduleByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupScheduleByIDAndUserParams{
|
||||||
Name: req.Name,
|
BackupSchedule: schedule,
|
||||||
UpdatedAt: now,
|
NextBackupAt: nextBackup,
|
||||||
ID: databaseID,
|
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||||
UserID: userID,
|
ID: databaseID,
|
||||||
})
|
UserID: userID,
|
||||||
default:
|
|
||||||
err = h.queries.UpdateDatabaseServicePlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServicePlanByIDAndUserParams{
|
|
||||||
Plan: req.Plan,
|
|
||||||
UpdatedAt: now,
|
|
||||||
ID: databaseID,
|
|
||||||
UserID: userID,
|
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup schedule"})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"})
|
if req.Name != "" || req.Plan != "" {
|
||||||
return
|
now := sql.NullTime{Time: time.Now(), Valid: true}
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case req.Name != "" && req.Plan != "":
|
||||||
|
err = h.queries.UpdateDatabaseServiceNameAndPlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameAndPlanByIDAndUserParams{
|
||||||
|
Name: req.Name,
|
||||||
|
Plan: req.Plan,
|
||||||
|
UpdatedAt: now,
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
case req.Name != "":
|
||||||
|
err = h.queries.UpdateDatabaseServiceNameByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameByIDAndUserParams{
|
||||||
|
Name: req.Name,
|
||||||
|
UpdatedAt: now,
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
err = h.queries.UpdateDatabaseServicePlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServicePlanByIDAndUserParams{
|
||||||
|
Plan: req.Plan,
|
||||||
|
UpdatedAt: now,
|
||||||
|
ID: databaseID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Database updated successfully"})
|
c.JSON(http.StatusOK, gin.H{"message": "Database updated successfully"})
|
||||||
@@ -839,9 +869,9 @@ func roundFloat(value float64, places int) float64 {
|
|||||||
return math.Round(value*pow) / pow
|
return math.Round(value*pow) / pow
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID, databaseID string) (DatabaseBackupConfig, error) {
|
func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string, db DatabaseService) (DatabaseBackupConfig, error) {
|
||||||
rows, err := h.queries.ListDatabaseBackupsByDatabaseAndUser(ctx, sqlcdb.ListDatabaseBackupsByDatabaseAndUserParams{
|
rows, err := h.queries.ListDatabaseBackupsByDatabaseAndUser(ctx, sqlcdb.ListDatabaseBackupsByDatabaseAndUserParams{
|
||||||
DatabaseID: databaseID,
|
DatabaseID: db.ID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Limit: 20,
|
Limit: 20,
|
||||||
})
|
})
|
||||||
@@ -865,17 +895,11 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID, datab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var nextBackup *time.Time
|
|
||||||
if lastBackup != nil {
|
|
||||||
t := lastBackup.Add(24 * time.Hour)
|
|
||||||
nextBackup = &t
|
|
||||||
}
|
|
||||||
|
|
||||||
return DatabaseBackupConfig{
|
return DatabaseBackupConfig{
|
||||||
Enabled: true,
|
Enabled: db.BackupSchedule != "",
|
||||||
LastBackup: lastBackup,
|
LastBackup: lastBackup,
|
||||||
Retention: 30,
|
Retention: 30,
|
||||||
NextBackup: nextBackup,
|
NextBackup: db.NextBackupAt,
|
||||||
Backups: backups,
|
Backups: backups,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -1017,30 +1041,34 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
|
|||||||
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
|
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
|
||||||
return DatabaseService{
|
return DatabaseService{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Type: row.Type,
|
Type: row.Type,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
Version: row.Version,
|
Version: row.Version,
|
||||||
Plan: row.Plan,
|
Plan: row.Plan,
|
||||||
Region: row.Region,
|
Region: row.Region,
|
||||||
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
BackupSchedule: databaseNullString(row.BackupSchedule),
|
||||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
||||||
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
||||||
|
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||||
|
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
|
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
|
||||||
return DatabaseService{
|
return DatabaseService{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
Name: row.Name,
|
Name: row.Name,
|
||||||
Type: row.Type,
|
Type: row.Type,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
Version: row.Version,
|
Version: row.Version,
|
||||||
Plan: row.Plan,
|
Plan: row.Plan,
|
||||||
Region: row.Region,
|
Region: row.Region,
|
||||||
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
BackupSchedule: databaseNullString(row.BackupSchedule),
|
||||||
CreatedAt: databaseNullTime(row.CreatedAt),
|
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
|
||||||
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
ConnectionURL: databaseNullString(row.ConnectionUrl),
|
||||||
|
CreatedAt: databaseNullTime(row.CreatedAt),
|
||||||
|
UpdatedAt: databaseNullTime(row.UpdatedAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1051,6 +1079,14 @@ func databaseNullTime(value sql.NullTime) time.Time {
|
|||||||
return time.Time{}
|
return time.Time{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func databaseNullTimePtr(value sql.NullTime) *time.Time {
|
||||||
|
if value.Valid {
|
||||||
|
t := value.Time
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func databaseNullString(value sql.NullString) string {
|
func databaseNullString(value sql.NullString) string {
|
||||||
if value.Valid {
|
if value.Valid {
|
||||||
return value.String
|
return value.String
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one
|
||||||
SELECT id, name, type, status, version, plan, region, connection_url, created_at, updated_at
|
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
`
|
`
|
||||||
@@ -161,16 +161,18 @@ type GetDatabaseServiceByIDAndUserParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetDatabaseServiceByIDAndUserRow struct {
|
type GetDatabaseServiceByIDAndUserRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Plan string `json:"plan"`
|
Plan string `json:"plan"`
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
ConnectionUrl sql.NullString `json:"connection_url"`
|
ConnectionUrl sql.NullString `json:"connection_url"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) {
|
func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) {
|
||||||
@@ -185,6 +187,8 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
|
|||||||
&i.Plan,
|
&i.Plan,
|
||||||
&i.Region,
|
&i.Region,
|
||||||
&i.ConnectionUrl,
|
&i.ConnectionUrl,
|
||||||
|
&i.BackupSchedule,
|
||||||
|
&i.NextBackupAt,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -238,23 +242,25 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many
|
||||||
SELECT id, name, type, status, version, plan, region, connection_url, created_at, updated_at
|
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListDatabaseServicesByUserRow struct {
|
type ListDatabaseServicesByUserRow struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Plan string `json:"plan"`
|
Plan string `json:"plan"`
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
ConnectionUrl sql.NullString `json:"connection_url"`
|
ConnectionUrl sql.NullString `json:"connection_url"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) {
|
func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) {
|
||||||
@@ -275,6 +281,8 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
|
|||||||
&i.Plan,
|
&i.Plan,
|
||||||
&i.Region,
|
&i.Region,
|
||||||
&i.ConnectionUrl,
|
&i.ConnectionUrl,
|
||||||
|
&i.BackupSchedule,
|
||||||
|
&i.NextBackupAt,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -291,6 +299,66 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listDueDatabaseBackups = `-- name: ListDueDatabaseBackups :many
|
||||||
|
SELECT id, backup_schedule
|
||||||
|
FROM database_services
|
||||||
|
WHERE backup_schedule IS NOT NULL AND backup_schedule <> ''
|
||||||
|
AND next_backup_at IS NOT NULL AND next_backup_at <= NOW()
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListDueDatabaseBackupsRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listDueDatabaseBackups)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []ListDueDatabaseBackupsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListDueDatabaseBackupsRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.BackupSchedule); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDatabaseBackupScheduleByIDAndUser = `-- name: SetDatabaseBackupScheduleByIDAndUser :exec
|
||||||
|
UPDATE database_services
|
||||||
|
SET backup_schedule = $1, next_backup_at = $2, updated_at = $3
|
||||||
|
WHERE id = $4 AND user_id = $5
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetDatabaseBackupScheduleByIDAndUserParams struct {
|
||||||
|
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||||
|
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setDatabaseBackupScheduleByIDAndUser,
|
||||||
|
arg.BackupSchedule,
|
||||||
|
arg.NextBackupAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
arg.ID,
|
||||||
|
arg.UserID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const setDatabaseBackupStatusByID = `-- name: SetDatabaseBackupStatusByID :exec
|
const setDatabaseBackupStatusByID = `-- name: SetDatabaseBackupStatusByID :exec
|
||||||
UPDATE database_backups
|
UPDATE database_backups
|
||||||
SET status = $1, size = $2, completed_at = $3
|
SET status = $1, size = $2, completed_at = $3
|
||||||
@@ -314,6 +382,22 @@ func (q *Queries) SetDatabaseBackupStatusByID(ctx context.Context, arg SetDataba
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setDatabaseNextBackupAt = `-- name: SetDatabaseNextBackupAt :exec
|
||||||
|
UPDATE database_services
|
||||||
|
SET next_backup_at = $1
|
||||||
|
WHERE id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetDatabaseNextBackupAtParams struct {
|
||||||
|
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, setDatabaseNextBackupAt, arg.NextBackupAt, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec
|
const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec
|
||||||
UPDATE database_services
|
UPDATE database_services
|
||||||
SET status = $1, connection_url = $2, updated_at = $3
|
SET status = $1, connection_url = $2, updated_at = $3
|
||||||
|
|||||||
@@ -80,17 +80,19 @@ type DatabaseBackup struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseService struct {
|
type DatabaseService struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Plan string `json:"plan"`
|
Plan string `json:"plan"`
|
||||||
Region string `json:"region"`
|
Region string `json:"region"`
|
||||||
ConnectionUrl sql.NullString `json:"connection_url"`
|
ConnectionUrl sql.NullString `json:"connection_url"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
BackupSchedule sql.NullString `json:"backup_schedule"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
NextBackupAt sql.NullTime `json:"next_backup_at"`
|
||||||
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Deployment struct {
|
type Deployment struct {
|
||||||
|
|||||||
@@ -49,12 +49,15 @@ type Querier interface {
|
|||||||
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
|
||||||
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
|
||||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
|
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
|
||||||
|
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||||
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
|
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
|
||||||
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error)
|
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error)
|
||||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||||
|
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||||
|
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
||||||
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
|
||||||
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
|
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
|
||||||
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE public.database_services
|
||||||
|
ADD COLUMN IF NOT EXISTS backup_schedule character varying(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS next_backup_at timestamp with time zone;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE public.database_services
|
||||||
|
DROP COLUMN IF EXISTS backup_schedule,
|
||||||
|
DROP COLUMN IF EXISTS next_backup_at;
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
-- name: ListDatabaseServicesByUser :many
|
-- name: ListDatabaseServicesByUser :many
|
||||||
SELECT id, name, type, status, version, plan, region, connection_url, created_at, updated_at
|
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: GetDatabaseServiceByIDAndUser :one
|
-- name: GetDatabaseServiceByIDAndUser :one
|
||||||
SELECT id, name, type, status, version, plan, region, connection_url, created_at, updated_at
|
SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at
|
||||||
FROM database_services
|
FROM database_services
|
||||||
WHERE id = $1 AND user_id = $2;
|
WHERE id = $1 AND user_id = $2;
|
||||||
|
|
||||||
@@ -79,3 +79,19 @@ VALUES ($1, $2, $3, $4, $5, $6);
|
|||||||
UPDATE database_backups
|
UPDATE database_backups
|
||||||
SET status = $1, size = $2, completed_at = $3
|
SET status = $1, size = $2, completed_at = $3
|
||||||
WHERE id = $4;
|
WHERE id = $4;
|
||||||
|
|
||||||
|
-- name: SetDatabaseBackupScheduleByIDAndUser :exec
|
||||||
|
UPDATE database_services
|
||||||
|
SET backup_schedule = $1, next_backup_at = $2, updated_at = $3
|
||||||
|
WHERE id = $4 AND user_id = $5;
|
||||||
|
|
||||||
|
-- name: ListDueDatabaseBackups :many
|
||||||
|
SELECT id, backup_schedule
|
||||||
|
FROM database_services
|
||||||
|
WHERE backup_schedule IS NOT NULL AND backup_schedule <> ''
|
||||||
|
AND next_backup_at IS NOT NULL AND next_backup_at <= NOW();
|
||||||
|
|
||||||
|
-- name: SetDatabaseNextBackupAt :exec
|
||||||
|
UPDATE database_services
|
||||||
|
SET next_backup_at = $1
|
||||||
|
WHERE id = $2;
|
||||||
@@ -96,6 +96,8 @@ CREATE TABLE database_services (
|
|||||||
plan VARCHAR(50) NOT NULL,
|
plan VARCHAR(50) NOT NULL,
|
||||||
region VARCHAR(50) NOT NULL,
|
region VARCHAR(50) NOT NULL,
|
||||||
connection_url TEXT,
|
connection_url TEXT,
|
||||||
|
backup_schedule VARCHAR(100),
|
||||||
|
next_backup_at TIMESTAMP WITH TIME ZONE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
revokeAgentToken,
|
revokeAgentToken,
|
||||||
listDatabases,
|
listDatabases,
|
||||||
databaseAction,
|
databaseAction,
|
||||||
|
updateDatabaseBackupSchedule,
|
||||||
createDatabaseBackup,
|
createDatabaseBackup,
|
||||||
restoreDatabaseBackup,
|
restoreDatabaseBackup,
|
||||||
listServicesByProject,
|
listServicesByProject,
|
||||||
@@ -1680,6 +1681,11 @@ export function DatabasesPage() {
|
|||||||
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<BackupScheduleRow
|
||||||
|
databaseId={db.id ?? ''}
|
||||||
|
schedule={db.backup_schedule ?? ''}
|
||||||
|
nextBackupAt={db.next_backup_at}
|
||||||
|
/>
|
||||||
{backups.length === 0 ? (
|
{backups.length === 0 ? (
|
||||||
<p className="text-xs text-[var(--text-muted)]">No backups yet.</p>
|
<p className="text-xs text-[var(--text-muted)]">No backups yet.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -1827,3 +1833,55 @@ function BindDatabasePanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BackupScheduleRow({
|
||||||
|
databaseId,
|
||||||
|
schedule,
|
||||||
|
nextBackupAt,
|
||||||
|
}: {
|
||||||
|
databaseId: string;
|
||||||
|
schedule: string;
|
||||||
|
nextBackupAt?: string;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [value, setValue] = useState(schedule);
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (expr: string) => updateDatabaseBackupSchedule(databaseId, expr),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['databases'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="cron schedule, e.g. 0 3 * * *"
|
||||||
|
className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => mutation.mutate(value.trim())}
|
||||||
|
disabled={mutation.isPending || value.trim() === schedule}
|
||||||
|
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Saving…' : 'Save schedule'}
|
||||||
|
</button>
|
||||||
|
{schedule && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setValue(''); mutation.mutate(''); }}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-[var(--text-muted)]">
|
||||||
|
{schedule ? `auto-backup ${schedule}${nextBackupAt ? ` — next ${formatRelative(nextBackupAt)}` : ''}` : 'manual only'}
|
||||||
|
</span>
|
||||||
|
{mutation.isError && (
|
||||||
|
<span className="text-xs text-[var(--error)]">
|
||||||
|
{mutation.error instanceof Error ? mutation.error.message : 'Failed to save schedule'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7059,6 +7059,13 @@ export interface components {
|
|||||||
plan?: "hobby" | "starter" | "standard" | "business";
|
plan?: "hobby" | "starter" | "standard" | "business";
|
||||||
/** @description Provisioning region */
|
/** @description Provisioning region */
|
||||||
region?: string;
|
region?: string;
|
||||||
|
/** @description Cron expression for automatic backups, empty when unset */
|
||||||
|
backup_schedule?: string;
|
||||||
|
/**
|
||||||
|
* Format: date-time
|
||||||
|
* @description Next scheduled backup run
|
||||||
|
*/
|
||||||
|
next_backup_at?: string;
|
||||||
metrics?: components["schemas"]["DatabaseMetrics"];
|
metrics?: components["schemas"]["DatabaseMetrics"];
|
||||||
backups?: components["schemas"]["DatabaseBackupConfig"];
|
backups?: components["schemas"]["DatabaseBackupConfig"];
|
||||||
settings?: components["schemas"]["DatabaseSettings"];
|
settings?: components["schemas"]["DatabaseSettings"];
|
||||||
@@ -7097,6 +7104,8 @@ export interface components {
|
|||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
plan?: "hobby" | "starter" | "standard" | "business";
|
plan?: "hobby" | "starter" | "standard" | "business";
|
||||||
|
/** @description Cron expression for automatic backups; empty string clears the schedule */
|
||||||
|
backup_schedule?: string | null;
|
||||||
};
|
};
|
||||||
DatabaseActionRequest: {
|
DatabaseActionRequest: {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1295,6 +1295,13 @@ export async function databaseAction(id: string, action: 'start' | 'stop' | 'res
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDatabaseBackupSchedule(id: string, schedule: string): Promise<void> {
|
||||||
|
await requestJson(`/databases/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ backup_schedule: schedule }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createDatabaseBackup(id: string): Promise<void> {
|
export async function createDatabaseBackup(id: string): Promise<void> {
|
||||||
await requestJson(`/databases/${encodeURIComponent(id)}/backup`, {
|
await requestJson(`/databases/${encodeURIComponent(id)}/backup`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -4778,6 +4778,13 @@ components:
|
|||||||
region:
|
region:
|
||||||
type: string
|
type: string
|
||||||
description: Provisioning region
|
description: Provisioning region
|
||||||
|
backup_schedule:
|
||||||
|
type: string
|
||||||
|
description: Cron expression for automatic backups, empty when unset
|
||||||
|
next_backup_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Next scheduled backup run
|
||||||
metrics:
|
metrics:
|
||||||
$ref: '#/components/schemas/DatabaseMetrics'
|
$ref: '#/components/schemas/DatabaseMetrics'
|
||||||
backups:
|
backups:
|
||||||
@@ -4826,6 +4833,10 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [hobby, starter, standard, business]
|
enum: [hobby, starter, standard, business]
|
||||||
description: Updated database plan
|
description: Updated database plan
|
||||||
|
backup_schedule:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Cron expression for automatic backups; empty string clears the schedule
|
||||||
|
|
||||||
DatabaseActionRequest:
|
DatabaseActionRequest:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
Reference in new issue
Block a user