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:
Tomas Dvorak committed 2026-09-17 17:46:02 +02:00
1 parent aeb91b03e9
commit 08d92fb10f
13 files changed
+387 -99

No files matched your search

+6 -3
View File
@@ -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
list. Verified e2e: byte-identical archive, 404 missing/cross-user,
401 unauthenticated.
- [ ] **Scheduled database backups**: backups are manual-only despite a
`retention` field. Add a `schedule` column + fold due database backups
into the cron scheduler tick.
- [x] **Scheduled database backups**: `database_services.backup_schedule`
(cron expr) + `next_backup_at`; `PUT /databases/:id` accepts
`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
"API-only" note in the spec. A request to `/g/{service}` with a valid key
+48
View File
@@ -2,8 +2,10 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"context"
"database/sql"
"fmt"
"log"
"net/http"
@@ -436,6 +438,7 @@ func StartCronScheduler(ctx context.Context, db *database.DB, dockerClient *dock
return
case <-ticker.C:
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
// the deployment naming convention containr-<serviceID>-*.
func findServiceContainer(ctx context.Context, dockerClient *docker.Client, serviceID string) (string, error) {
+97 -61
View File
@@ -47,6 +47,8 @@ type DatabaseService struct {
Version string `json:"version" db:"version"`
Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business
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"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ConnectionURL string `json:"connection_url"`
@@ -102,8 +104,9 @@ type DatabaseCreateRequest struct {
// DatabaseUpdateRequest represents a request to update a database
type DatabaseUpdateRequest struct {
Name string `json:"name,omitempty"`
Plan string `json:"plan,omitempty"`
Name string `json:"name,omitempty"`
Plan string `json:"plan,omitempty"`
BackupSchedule *string `json:"backup_schedule,omitempty"`
}
// 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.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
} else {
db.Backups = h.generateMockBackupConfig()
@@ -220,7 +223,7 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
db = h.reconcileManagedDatabaseState(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
} else {
db.Backups = h.generateMockBackupConfig()
@@ -364,40 +367,67 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
req.Name = strings.TrimSpace(req.Name)
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"})
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 req.BackupSchedule != nil {
var schedule sql.NullString
var nextBackup sql.NullTime
if expr := strings.TrimSpace(*req.BackupSchedule); expr != "" {
next, err := calculateNextRun(expr, "UTC")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cron schedule"})
return
}
schedule = sql.NullString{String: expr, Valid: true}
nextBackup = sql.NullTime{Time: *next, Valid: true}
}
err := h.queries.SetDatabaseBackupScheduleByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupScheduleByIDAndUserParams{
BackupSchedule: schedule,
NextBackupAt: nextBackup,
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
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"})
return
if req.Name != "" || req.Plan != "" {
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"})
@@ -839,9 +869,9 @@ func roundFloat(value float64, places int) float64 {
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{
DatabaseID: databaseID,
DatabaseID: db.ID,
UserID: userID,
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{
Enabled: true,
Enabled: db.BackupSchedule != "",
LastBackup: lastBackup,
Retention: 30,
NextBackup: nextBackup,
NextBackup: db.NextBackupAt,
Backups: backups,
}, nil
}
@@ -1017,30 +1041,34 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
return DatabaseService{
ID: row.ID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
BackupSchedule: databaseNullString(row.BackupSchedule),
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
}
}
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
return DatabaseService{
ID: row.ID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
ID: row.ID,
Name: row.Name,
Type: row.Type,
Status: row.Status,
Version: row.Version,
Plan: row.Plan,
Region: row.Region,
BackupSchedule: databaseNullString(row.BackupSchedule),
NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
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{}
}
func databaseNullTimePtr(value sql.NullTime) *time.Time {
if value.Valid {
t := value.Time
return &t
}
return nil
}
func databaseNullString(value sql.NullString) string {
if value.Valid {
return value.String
@@ -150,7 +150,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
}
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
WHERE id = $1 AND user_id = $2
`
@@ -161,16 +161,18 @@ type GetDatabaseServiceByIDAndUserParams struct {
}
type GetDatabaseServiceByIDAndUserRow struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"`
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) {
@@ -185,6 +187,8 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData
&i.Plan,
&i.Region,
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.CreatedAt,
&i.UpdatedAt,
)
@@ -238,23 +242,25 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
}
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
WHERE user_id = $1
ORDER BY created_at DESC
`
type ListDatabaseServicesByUserRow struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"`
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) {
@@ -275,6 +281,8 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
&i.Plan,
&i.Region,
&i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
@@ -291,6 +299,66 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
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
UPDATE database_backups
SET status = $1, size = $2, completed_at = $3
@@ -314,6 +382,22 @@ func (q *Queries) SetDatabaseBackupStatusByID(ctx context.Context, arg SetDataba
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
UPDATE database_services
SET status = $1, connection_url = $2, updated_at = $3
+13 -11
View File
@@ -80,17 +80,19 @@ type DatabaseBackup struct {
}
type DatabaseService struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
Version string `json:"version"`
Plan string `json:"plan"`
Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"`
BackupSchedule sql.NullString `json:"backup_schedule"`
NextBackupAt sql.NullTime `json:"next_backup_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type Deployment struct {
@@ -49,12 +49,15 @@ type Querier interface {
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error)
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) 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;
+18 -2
View File
@@ -1,11 +1,11 @@
-- 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
WHERE user_id = $1
ORDER BY created_at DESC;
-- 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
WHERE id = $1 AND user_id = $2;
@@ -79,3 +79,19 @@ VALUES ($1, $2, $3, $4, $5, $6);
UPDATE database_backups
SET status = $1, size = $2, completed_at = $3
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;
+2
View File
@@ -96,6 +96,8 @@ CREATE TABLE database_services (
plan VARCHAR(50) NOT NULL,
region VARCHAR(50) NOT NULL,
connection_url TEXT,
backup_schedule VARCHAR(100),
next_backup_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
@@ -20,6 +20,7 @@ import {
revokeAgentToken,
listDatabases,
databaseAction,
updateDatabaseBackupSchedule,
createDatabaseBackup,
restoreDatabaseBackup,
listServicesByProject,
@@ -1680,6 +1681,11 @@ export function DatabasesPage() {
{backupMutation.isPending ? 'Starting…' : 'New backup'}
</button>
</div>
<BackupScheduleRow
databaseId={db.id ?? ''}
schedule={db.backup_schedule ?? ''}
nextBackupAt={db.next_backup_at}
/>
{backups.length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No backups yet.</p>
) : (
@@ -1827,3 +1833,55 @@ function BindDatabasePanel({
</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>
);
}
+9
View File
@@ -7059,6 +7059,13 @@ export interface components {
plan?: "hobby" | "starter" | "standard" | "business";
/** @description Provisioning region */
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"];
backups?: components["schemas"]["DatabaseBackupConfig"];
settings?: components["schemas"]["DatabaseSettings"];
@@ -7097,6 +7104,8 @@ export interface components {
* @enum {string}
*/
plan?: "hobby" | "starter" | "standard" | "business";
/** @description Cron expression for automatic backups; empty string clears the schedule */
backup_schedule?: string | null;
};
DatabaseActionRequest: {
/**
+7
View File
@@ -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> {
await requestJson(`/databases/${encodeURIComponent(id)}/backup`, {
method: 'POST',
+11
View File
@@ -4778,6 +4778,13 @@ components:
region:
type: string
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:
$ref: '#/components/schemas/DatabaseMetrics'
backups:
@@ -4826,6 +4833,10 @@ components:
type: string
enum: [hobby, starter, standard, business]
description: Updated database plan
backup_schedule:
type: string
nullable: true
description: Cron expression for automatic backups; empty string clears the schedule
DatabaseActionRequest:
type: object