diff --git a/docker/Dockerfile b/docker/Dockerfile index 46df87d..b871cb9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -19,7 +19,9 @@ ENV DEBIAN_FRONTEND=noninteractive # Install system dependencies RUN apt-get update && \ - apt-get install -y wget git gnupg2 lsb-release && \ + apt-get install -y \ + wget=1.21.4-1ubuntu4.1 unzip=6.0-28ubuntu4 git=1:2.43.0-1ubuntu7.1 \ + gnupg2=2.4.4-2ubuntu17 lsb-release=12.0-2 && \ rm -rf /var/lib/apt/lists/* # Add PostgreSQL repository and key diff --git a/docker/Dockerfile.cicd b/docker/Dockerfile.cicd index 1a778fa..a49aa22 100644 --- a/docker/Dockerfile.cicd +++ b/docker/Dockerfile.cicd @@ -19,7 +19,9 @@ ENV DEBIAN_FRONTEND=noninteractive # Install system dependencies RUN apt-get update && \ - apt-get install -y wget git gnupg2 lsb-release && \ + apt-get install -y \ + wget=1.21.4-1ubuntu4.1 unzip=6.0-28ubuntu4 git=1:2.43.0-1ubuntu7.1 \ + gnupg2=2.4.4-2ubuntu17 lsb-release=12.0-2 && \ rm -rf /var/lib/apt/lists/* # Add PostgreSQL repository and key diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 826a37f..af68a1b 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -19,7 +19,9 @@ ENV DEBIAN_FRONTEND=noninteractive # Install system dependencies RUN apt-get update && \ - apt-get install -y wget git gnupg2 lsb-release && \ + apt-get install -y \ + wget=1.21.4-1ubuntu4.1 unzip=6.0-28ubuntu4 git=1:2.43.0-1ubuntu7.1 \ + gnupg2=2.4.4-2ubuntu17 lsb-release=12.0-2 && \ rm -rf /var/lib/apt/lists/* # Add PostgreSQL repository and key diff --git a/internal/database/migrations/20240805000451_add_restorations_table.sql b/internal/database/migrations/20240805000451_add_restorations_table.sql new file mode 100644 index 0000000..4f18117 --- /dev/null +++ b/internal/database/migrations/20240805000451_add_restorations_table.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS restorations ( + id UUID NOT NULL DEFAULT uuid_generate_v4() PRIMARY KEY, + execution_id UUID NOT NULL REFERENCES executions(id) ON DELETE CASCADE, + database_id UUID REFERENCES databases(id) ON DELETE CASCADE, + + status TEXT NOT NULL CHECK ( + status IN ('running', 'success', 'failed') + ) DEFAULT 'running', + message TEXT, + + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ +); + +CREATE TRIGGER restorations_change_updated_at +BEFORE UPDATE ON restorations FOR EACH ROW EXECUTE FUNCTION change_updated_at(); + +CREATE INDEX IF NOT EXISTS +idx_restorations_execution_id ON restorations(execution_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS restorations; +-- +goose StatementEnd diff --git a/internal/integration/postgres/postgres.go b/internal/integration/postgres/postgres.go index f50784b..83d8ffc 100644 --- a/internal/integration/postgres/postgres.go +++ b/internal/integration/postgres/postgres.go @@ -5,8 +5,10 @@ import ( "bytes" "fmt" "io" + "os" "os/exec" + "github.com/eduardolat/pgbackweb/internal/util/strutil" "github.com/orsinium-labs/enum" ) @@ -195,3 +197,65 @@ func (c *Client) DumpZip( return reader } + +// RestoreZip downloads or copies the ZIP from the given url or path, unzips it, +// and runs the psql command to restore the database. +// +// The ZIP file must contain a dump.sql file with the SQL dump to restore. +// +// - version: PostgreSQL version to use for the restore +// - connString: connection string to the database +// - isLocal: whether the ZIP file is local or a URL +// - zipURLOrPath: URL or path to the ZIP file +func (Client) RestoreZip( + version PGVersion, connString string, isLocal bool, zipURLOrPath string, +) error { + workDir, err := os.MkdirTemp("", "pbw-restore-*") + if err != nil { + return fmt.Errorf("error creating temp dir: %w", err) + } + defer os.RemoveAll(workDir) + zipPath := strutil.CreatePath(true, workDir, "dump.zip") + dumpPath := strutil.CreatePath(true, workDir, "dump.sql") + + if isLocal { + cmd := exec.Command("cp", zipURLOrPath, zipPath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("error copying ZIP file to temp dir: %s", output) + } + } + + if !isLocal { + cmd := exec.Command("wget", "--no-verbose", "-O", zipPath, zipURLOrPath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("error downloading ZIP file: %s", output) + } + } + + if _, err := os.Stat(zipPath); os.IsNotExist(err) { + return fmt.Errorf("zip file not found: %s", zipPath) + } + + cmd := exec.Command("unzip", "-o", zipPath, "dump.sql", "-d", workDir) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("error unzipping ZIP file: %s", output) + } + + if _, err := os.Stat(dumpPath); os.IsNotExist(err) { + return fmt.Errorf("dump.sql file not found in ZIP file: %s", zipPath) + } + + cmd = exec.Command(version.Value.psql, connString, "-f", dumpPath) + output, err = cmd.CombinedOutput() + if err != nil { + return fmt.Errorf( + "error running psql v%s command: %s", + version.Value.version, output, + ) + } + + return nil +} diff --git a/internal/service/executions/get_execution.go b/internal/service/executions/get_execution.go index 578e061..4f4900b 100644 --- a/internal/service/executions/get_execution.go +++ b/internal/service/executions/get_execution.go @@ -9,6 +9,6 @@ import ( func (s *Service) GetExecution( ctx context.Context, id uuid.UUID, -) (dbgen.Execution, error) { +) (dbgen.ExecutionsServiceGetExecutionRow, error) { return s.dbgen.ExecutionsServiceGetExecution(ctx, id) } diff --git a/internal/service/executions/get_execution.sql b/internal/service/executions/get_execution.sql index 076153a..0420e2f 100644 --- a/internal/service/executions/get_execution.sql +++ b/internal/service/executions/get_execution.sql @@ -1,3 +1,9 @@ -- name: ExecutionsServiceGetExecution :one -SELECT * FROM executions -WHERE id = @id; +SELECT + executions.*, + databases.id AS database_id, + databases.pg_version AS database_pg_version +FROM executions +INNER JOIN backups ON backups.id = executions.backup_id +INNER JOIN databases ON databases.id = backups.database_id +WHERE executions.id = @id; diff --git a/internal/service/executions/paginate_executions.sql b/internal/service/executions/paginate_executions.sql index 5c4c98c..d24ad97 100644 --- a/internal/service/executions/paginate_executions.sql +++ b/internal/service/executions/paginate_executions.sql @@ -28,6 +28,7 @@ SELECT executions.*, backups.name AS backup_name, databases.name AS database_name, + databases.pg_version AS database_pg_version, destinations.name AS destination_name, backups.is_local AS backup_is_local FROM executions diff --git a/internal/service/restorations/create_restoration.go b/internal/service/restorations/create_restoration.go new file mode 100644 index 0000000..b1ce32e --- /dev/null +++ b/internal/service/restorations/create_restoration.go @@ -0,0 +1,13 @@ +package restorations + +import ( + "context" + + "github.com/eduardolat/pgbackweb/internal/database/dbgen" +) + +func (s *Service) CreateRestoration( + ctx context.Context, params dbgen.RestorationsServiceCreateRestorationParams, +) (dbgen.Restoration, error) { + return s.dbgen.RestorationsServiceCreateRestoration(ctx, params) +} diff --git a/internal/service/restorations/create_restoration.sql b/internal/service/restorations/create_restoration.sql new file mode 100644 index 0000000..1eb655f --- /dev/null +++ b/internal/service/restorations/create_restoration.sql @@ -0,0 +1,4 @@ +-- name: RestorationsServiceCreateRestoration :one +INSERT INTO restorations (execution_id, database_id, status, message) +VALUES (@execution_id, @database_id, @status, @message) +RETURNING *; diff --git a/internal/service/restorations/get_restorations_qty.go b/internal/service/restorations/get_restorations_qty.go new file mode 100644 index 0000000..118f6fd --- /dev/null +++ b/internal/service/restorations/get_restorations_qty.go @@ -0,0 +1,7 @@ +package restorations + +import "context" + +func (s *Service) GetRestorationsQty(ctx context.Context) (int64, error) { + return s.dbgen.RestorationsServiceGetRestorationsQty(ctx) +} diff --git a/internal/service/restorations/get_restorations_qty.sql b/internal/service/restorations/get_restorations_qty.sql new file mode 100644 index 0000000..f12fd35 --- /dev/null +++ b/internal/service/restorations/get_restorations_qty.sql @@ -0,0 +1,2 @@ +-- name: RestorationsServiceGetRestorationsQty :one +SELECT COUNT(*) FROM restorations; diff --git a/internal/service/restorations/paginate_restorations.go b/internal/service/restorations/paginate_restorations.go new file mode 100644 index 0000000..0a25f74 --- /dev/null +++ b/internal/service/restorations/paginate_restorations.go @@ -0,0 +1,54 @@ +package restorations + +import ( + "context" + + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/paginateutil" + "github.com/google/uuid" +) + +type PaginateRestorationsParams struct { + Page int + Limit int + ExecutionFilter uuid.NullUUID + DatabaseFilter uuid.NullUUID +} + +func (s *Service) PaginateRestorations( + ctx context.Context, params PaginateRestorationsParams, +) (paginateutil.PaginateResponse, []dbgen.RestorationsServicePaginateRestorationsRow, error) { + page := max(params.Page, 1) + limit := min(max(params.Limit, 1), 100) + + count, err := s.dbgen.RestorationsServicePaginateRestorationsCount( + ctx, dbgen.RestorationsServicePaginateRestorationsCountParams{ + ExecutionID: params.ExecutionFilter, + DatabaseID: params.DatabaseFilter, + }, + ) + if err != nil { + return paginateutil.PaginateResponse{}, nil, err + } + + paginateParams := paginateutil.PaginateParams{ + Page: page, + Limit: limit, + } + offset := paginateutil.CreateOffsetFromParams(paginateParams) + paginateResponse := paginateutil.CreatePaginateResponse(paginateParams, int(count)) + + restorations, err := s.dbgen.RestorationsServicePaginateRestorations( + ctx, dbgen.RestorationsServicePaginateRestorationsParams{ + ExecutionID: params.ExecutionFilter, + DatabaseID: params.DatabaseFilter, + Limit: int32(params.Limit), + Offset: int32(offset), + }, + ) + if err != nil { + return paginateutil.PaginateResponse{}, nil, err + } + + return paginateResponse, restorations, nil +} diff --git a/internal/service/restorations/paginate_restorations.sql b/internal/service/restorations/paginate_restorations.sql new file mode 100644 index 0000000..c52c950 --- /dev/null +++ b/internal/service/restorations/paginate_restorations.sql @@ -0,0 +1,42 @@ +-- name: RestorationsServicePaginateRestorationsCount :one +SELECT COUNT(restorations.*) +FROM restorations +INNER JOIN executions ON executions.id = restorations.execution_id +INNER JOIN backups ON backups.id = executions.backup_id +LEFT JOIN databases ON databases.id = restorations.database_id +WHERE +( + sqlc.narg('execution_id')::UUID IS NULL + OR + restorations.execution_id = sqlc.narg('execution_id')::UUID +) +AND +( + sqlc.narg('database_id')::UUID IS NULL + OR + restorations.database_id = sqlc.narg('database_id')::UUID +); + +-- name: RestorationsServicePaginateRestorations :many +SELECT + restorations.*, + databases.name AS database_name, + backups.name AS backup_name +FROM restorations +INNER JOIN executions ON executions.id = restorations.execution_id +INNER JOIN backups ON backups.id = executions.backup_id +LEFT JOIN databases ON databases.id = restorations.database_id +WHERE +( + sqlc.narg('execution_id')::UUID IS NULL + OR + restorations.execution_id = sqlc.narg('execution_id')::UUID +) +AND +( + sqlc.narg('database_id')::UUID IS NULL + OR + restorations.database_id = sqlc.narg('database_id')::UUID +) +ORDER BY restorations.started_at DESC +LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); diff --git a/internal/service/restorations/restorations.go b/internal/service/restorations/restorations.go new file mode 100644 index 0000000..a155895 --- /dev/null +++ b/internal/service/restorations/restorations.go @@ -0,0 +1,31 @@ +package restorations + +import ( + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/integration" + "github.com/eduardolat/pgbackweb/internal/service/databases" + "github.com/eduardolat/pgbackweb/internal/service/destinations" + "github.com/eduardolat/pgbackweb/internal/service/executions" +) + +type Service struct { + dbgen *dbgen.Queries + ints *integration.Integration + executionsService *executions.Service + databasesService *databases.Service + destinationsService *destinations.Service +} + +func New( + dbgen *dbgen.Queries, ints *integration.Integration, + executionsService *executions.Service, databasesService *databases.Service, + destinationsService *destinations.Service, +) *Service { + return &Service{ + dbgen: dbgen, + ints: ints, + executionsService: executionsService, + databasesService: databasesService, + destinationsService: destinationsService, + } +} diff --git a/internal/service/restorations/run_restoration.go b/internal/service/restorations/run_restoration.go new file mode 100644 index 0000000..78edbd8 --- /dev/null +++ b/internal/service/restorations/run_restoration.go @@ -0,0 +1,155 @@ +package restorations + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/logger" + "github.com/google/uuid" +) + +// RunRestoration runs a backup restoration +func (s *Service) RunRestoration( + ctx context.Context, + executionID uuid.UUID, + databaseID uuid.NullUUID, + connString string, +) error { + updateRes := func(params dbgen.RestorationsServiceUpdateRestorationParams) error { + _, err := s.dbgen.RestorationsServiceUpdateRestoration( + ctx, params, + ) + return err + } + + logError := func(err error) { + dbID := "empty" + if databaseID.Valid { + dbID = databaseID.UUID.String() + } + logger.Error("error running restoration", logger.KV{ + "execution_id": executionID.String(), + "database_id": dbID, + "error": err.Error(), + }) + } + + res, err := s.CreateRestoration(ctx, dbgen.RestorationsServiceCreateRestorationParams{ + ExecutionID: executionID, + DatabaseID: databaseID, + Status: "running", + }) + if err != nil { + logError(err) + return err + } + + if !databaseID.Valid && connString == "" { + err := fmt.Errorf("database_id or connection_string must be provided") + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + execution, err := s.executionsService.GetExecution(ctx, executionID) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + if execution.Status != "success" || !execution.Path.Valid { + err := fmt.Errorf("backup execution must be successful") + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + if databaseID.Valid { + db, err := s.databasesService.GetDatabase(ctx, databaseID.UUID) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + connString = db.DecryptedConnectionString + } + + pgVersion, err := s.ints.PGClient.ParseVersion(execution.DatabasePgVersion) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + err = s.ints.PGClient.Ping(pgVersion, connString) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + isLocal, zipURLOrPath, err := s.executionsService.GetExecutionDownloadLinkOrPath( + ctx, executionID, + ) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + err = s.ints.PGClient.RestoreZip( + pgVersion, connString, isLocal, zipURLOrPath, + ) + if err != nil { + logError(err) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + + logger.Info("backup restored successfully", logger.KV{ + "restoration_id": res.ID.String(), + "execution_id": executionID.String(), + }) + return updateRes(dbgen.RestorationsServiceUpdateRestorationParams{ + ID: res.ID, + Status: sql.NullString{Valid: true, String: "success"}, + Message: sql.NullString{Valid: true, String: "Backup restored successfully"}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) +} diff --git a/internal/service/restorations/update_restoration.go b/internal/service/restorations/update_restoration.go new file mode 100644 index 0000000..965f0b2 --- /dev/null +++ b/internal/service/restorations/update_restoration.go @@ -0,0 +1,13 @@ +package restorations + +import ( + "context" + + "github.com/eduardolat/pgbackweb/internal/database/dbgen" +) + +func (s *Service) UpdateRestoration( + ctx context.Context, params dbgen.RestorationsServiceUpdateRestorationParams, +) (dbgen.Restoration, error) { + return s.dbgen.RestorationsServiceUpdateRestoration(ctx, params) +} diff --git a/internal/service/restorations/update_restoration.sql b/internal/service/restorations/update_restoration.sql new file mode 100644 index 0000000..1505ee7 --- /dev/null +++ b/internal/service/restorations/update_restoration.sql @@ -0,0 +1,8 @@ +-- name: RestorationsServiceUpdateRestoration :one +UPDATE restorations +SET + status = COALESCE(sqlc.narg('status'), status), + message = COALESCE(sqlc.narg('message'), message), + finished_at = COALESCE(sqlc.narg('finished_at'), finished_at) +WHERE id = @id +RETURNING *; diff --git a/internal/service/service.go b/internal/service/service.go index da12a18..2f3abc1 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -10,6 +10,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/service/databases" "github.com/eduardolat/pgbackweb/internal/service/destinations" "github.com/eduardolat/pgbackweb/internal/service/executions" + "github.com/eduardolat/pgbackweb/internal/service/restorations" "github.com/eduardolat/pgbackweb/internal/service/users" ) @@ -20,6 +21,7 @@ type Service struct { DestinationsService *destinations.Service ExecutionsService *executions.Service UsersService *users.Service + RestorationsService *restorations.Service } func New( @@ -32,6 +34,9 @@ func New( executionsService := executions.New(env, dbgen, ints) usersService := users.New(dbgen) backupsService := backups.New(dbgen, cr, executionsService) + restorationsService := restorations.New( + dbgen, ints, executionsService, databasesService, destinationsService, + ) return &Service{ AuthService: authService, @@ -40,5 +45,6 @@ func New( DestinationsService: destinationsService, ExecutionsService: executionsService, UsersService: usersService, + RestorationsService: restorationsService, } } diff --git a/internal/view/web/dashboard/executions/list_executions.go b/internal/view/web/dashboard/executions/list_executions.go index dc5f1b8..0f8c7aa 100644 --- a/internal/view/web/dashboard/executions/list_executions.go +++ b/internal/view/web/dashboard/executions/list_executions.go @@ -74,6 +74,7 @@ func listExecutions( html.Div( html.Class("flex justify-start space-x-1"), showExecutionButton(execution), + restoreExecutionButton(execution), ), ), html.Td(component.StatusBadge(execution.Status)), diff --git a/internal/view/web/dashboard/executions/restore_execution.go b/internal/view/web/dashboard/executions/restore_execution.go new file mode 100644 index 0000000..dfccbbf --- /dev/null +++ b/internal/view/web/dashboard/executions/restore_execution.go @@ -0,0 +1,246 @@ +package executions + +import ( + "context" + "fmt" + "net/http" + + lucide "github.com/eduardolat/gomponents-lucide" + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/validate" + "github.com/eduardolat/pgbackweb/internal/view/web/alpine" + "github.com/eduardolat/pgbackweb/internal/view/web/component" + "github.com/eduardolat/pgbackweb/internal/view/web/htmx" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/maragudk/gomponents" + "github.com/maragudk/gomponents/html" +) + +func (h *handlers) restoreExecutionHandler(c echo.Context) error { + ctx := c.Request().Context() + + var formData struct { + ExecutionID uuid.UUID `form:"execution_id" validate:"required,uuid"` + DatabaseID uuid.UUID `form:"database_id" validate:"omitempty,uuid"` + ConnString string `form:"conn_string" validate:"omitempty"` + } + if err := c.Bind(&formData); err != nil { + return htmx.RespondToastError(c, err.Error()) + } + if err := validate.Struct(&formData); err != nil { + return htmx.RespondToastError(c, err.Error()) + } + + if formData.DatabaseID == uuid.Nil && formData.ConnString == "" { + return htmx.RespondToastError( + c, "Database or connection string is required", + ) + } + + if formData.DatabaseID != uuid.Nil && formData.ConnString != "" { + return htmx.RespondToastError( + c, "Database and connection string cannot be both set", + ) + } + + execution, err := h.servs.ExecutionsService.GetExecution( + ctx, formData.ExecutionID, + ) + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } + + if formData.ConnString != "" { + err := h.servs.DatabasesService.TestDatabase( + ctx, execution.DatabasePgVersion, formData.ConnString, + ) + if err != nil { + return htmx.RespondToastError(c, err.Error()) + } + } + + go func() { + ctx := context.Background() + _ = h.servs.RestorationsService.RunRestoration( + ctx, + formData.ExecutionID, + uuid.NullUUID{ + Valid: formData.DatabaseID != uuid.Nil, + UUID: formData.DatabaseID, + }, + formData.ConnString, + ) + }() + + return htmx.RespondToastSuccess( + c, "Process started, check the restorations page for more details", + ) +} + +func (h *handlers) restoreExecutionFormHandler(c echo.Context) error { + ctx := c.Request().Context() + + executionID, err := uuid.Parse(c.Param("executionID")) + if err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + + execution, err := h.servs.ExecutionsService.GetExecution(ctx, executionID) + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } + + databases, err := h.servs.DatabasesService.GetAllDatabases(ctx) + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } + + return echoutil.RenderGomponent(c, http.StatusOK, restoreExecutionForm( + execution, databases, + )) +} + +func restoreExecutionForm( + execution dbgen.ExecutionsServiceGetExecutionRow, + databases []dbgen.DatabasesServiceGetAllDatabasesRow, +) gomponents.Node { + return html.Form( + htmx.HxPost("/dashboard/executions/"+execution.ID.String()+"/restore"), + htmx.HxConfirm("Are you sure you want to restore this backup?"), + htmx.HxDisabledELT("find button"), + + alpine.XData(`{ backup_to: "database" }`), + + html.Input( + html.Type("hidden"), + html.Name("execution_id"), + html.Value(execution.ID.String()), + ), + + html.Div( + html.Class("space-y-2 text-base"), + + component.SelectControl(component.SelectControlParams{ + Name: "backup_to", + Label: "Backup to", + Required: true, + HelpText: "You can restore the backup to an existing database or any other database using a connection string", + Children: []gomponents.Node{ + alpine.XModel("backup_to"), + html.Option( + html.Value("database"), + gomponents.Text("Existing database"), + html.Selected(), + ), + html.Option( + html.Value("conn_string"), + gomponents.Text("Other database"), + ), + }, + }), + + alpine.Template( + alpine.XIf("backup_to === 'database'"), + component.SelectControl(component.SelectControlParams{ + Name: "database_id", + Label: "Database", + Placeholder: "Select a database", + Required: true, + Children: []gomponents.Node{ + component.GMap( + databases, + func(db dbgen.DatabasesServiceGetAllDatabasesRow) gomponents.Node { + return html.Option( + html.Value(db.ID.String()), + gomponents.Text(db.Name), + gomponents.If( + db.ID == execution.DatabaseID, + html.Selected(), + ), + ) + }, + ), + }, + }), + ), + + alpine.Template( + alpine.XIf("backup_to === 'conn_string'"), + component.InputControl(component.InputControlParams{ + Name: "conn_string", + Label: "Connection string", + Placeholder: "postgresql://user:password@localhost:5432/mydb", + Type: component.InputTypeText, + Required: true, + }), + ), + + html.Div( + html.Class("pt-2"), + html.Div( + html.Role("alert"), + html.Class("alert alert-warning"), + lucide.TriangleAlert(), + html.Div( + html.P( + component.BText(fmt.Sprintf( + "This restoration uses psql v%s", execution.DatabasePgVersion, + )), + ), + component.PText(` + Please make sure the database you are restoring to is compatible + with this version of psql and double-check that the picked + database is the one you want to restore to. + `), + ), + ), + ), + + html.Div( + html.Class("flex justify-end items-center space-x-2 pt-2"), + component.HxLoadingMd(), + html.Button( + html.Class("btn btn-primary"), + html.Type("submit"), + component.SpanText("Start restoration"), + lucide.Zap(), + ), + ), + ), + ) +} + +func restoreExecutionButton(execution dbgen.ExecutionsServicePaginateExecutionsRow) gomponents.Node { + if execution.Status != "success" || !execution.Path.Valid { + return nil + } + + mo := component.Modal(component.ModalParams{ + Size: component.SizeMd, + Title: "Restore backup execution", + Content: []gomponents.Node{ + html.Div( + htmx.HxGet("/dashboard/executions/"+execution.ID.String()+"/restore-form"), + htmx.HxSwap("outerHTML"), + htmx.HxTrigger("intersect once"), + html.Class("p-10 flex justify-center"), + component.HxLoadingMd(), + ), + }, + }) + + button := html.Button( + mo.OpenerAttr, + html.Class("btn btn-square btn-sm btn-ghost"), + lucide.ArchiveRestore(), + ) + + return html.Div( + html.Class("inline-block tooltip tooltip-right"), + html.Data("tip", "Restore backup execution"), + mo.HTML, + button, + ) +} diff --git a/internal/view/web/dashboard/executions/router.go b/internal/view/web/dashboard/executions/router.go index fa57ecc..1a16857 100644 --- a/internal/view/web/dashboard/executions/router.go +++ b/internal/view/web/dashboard/executions/router.go @@ -23,4 +23,6 @@ func MountRouter( parent.GET("/list", h.listExecutionsHandler) parent.GET("/:executionID/download", h.downloadExecutionHandler) parent.DELETE("/:executionID", h.deleteExecutionHandler) + parent.GET("/:executionID/restore-form", h.restoreExecutionFormHandler) + parent.POST("/:executionID/restore", h.restoreExecutionHandler) } diff --git a/internal/view/web/dashboard/restorations/index.go b/internal/view/web/dashboard/restorations/index.go new file mode 100644 index 0000000..4738c78 --- /dev/null +++ b/internal/view/web/dashboard/restorations/index.go @@ -0,0 +1,86 @@ +package restorations + +import ( + "net/http" + + "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/strutil" + "github.com/eduardolat/pgbackweb/internal/validate" + "github.com/eduardolat/pgbackweb/internal/view/web/component" + "github.com/eduardolat/pgbackweb/internal/view/web/htmx" + "github.com/eduardolat/pgbackweb/internal/view/web/layout" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/maragudk/gomponents" + "github.com/maragudk/gomponents/html" +) + +type resQueryData struct { + Execution uuid.UUID `query:"execution" validate:"omitempty,uuid"` + Database uuid.UUID `query:"database" validate:"omitempty,uuid"` +} + +func (h *handlers) indexPageHandler(c echo.Context) error { + var queryData resQueryData + if err := c.Bind(&queryData); err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + if err := validate.Struct(&queryData); err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + + return echoutil.RenderGomponent(c, http.StatusOK, indexPage(queryData)) +} + +func indexPage(queryData resQueryData) gomponents.Node { + content := []gomponents.Node{ + component.H1Text("Restorations"), + component.CardBox(component.CardBoxParams{ + Class: "mt-4", + Children: []gomponents.Node{ + html.Div( + html.Class("overflow-x-auto"), + html.Table( + html.Class("table text-nowrap"), + html.THead( + html.Tr( + html.Th(component.SpanText("Actions")), + html.Th(component.SpanText("Status")), + html.Th(component.SpanText("Backup")), + html.Th(component.SpanText("Database")), + html.Th(component.SpanText("Execution")), + html.Th(component.SpanText("Started at")), + html.Th(component.SpanText("Finished at")), + html.Th(component.SpanText("Duration")), + ), + ), + html.TBody( + htmx.HxGet(func() string { + url := "/dashboard/restorations/list?page=1" + if queryData.Execution != uuid.Nil { + url = strutil.AddQueryParamToUrl(url, "execution", queryData.Execution.String()) + } + if queryData.Database != uuid.Nil { + url = strutil.AddQueryParamToUrl(url, "database", queryData.Database.String()) + } + return url + }()), + htmx.HxTrigger("load"), + htmx.HxIndicator("#list-restorations-loading"), + ), + ), + ), + + html.Div( + html.Class("flex justify-center mt-4"), + component.HxLoadingLg("list-restorations-loading"), + ), + }, + }), + } + + return layout.Dashboard(layout.DashboardParams{ + Title: "Restorations", + Body: content, + }) +} diff --git a/internal/view/web/dashboard/restorations/list_restorations.go b/internal/view/web/dashboard/restorations/list_restorations.go new file mode 100644 index 0000000..7b82b3d --- /dev/null +++ b/internal/view/web/dashboard/restorations/list_restorations.go @@ -0,0 +1,126 @@ +package restorations + +import ( + "fmt" + "net/http" + + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/service/restorations" + "github.com/eduardolat/pgbackweb/internal/util/echoutil" + "github.com/eduardolat/pgbackweb/internal/util/paginateutil" + "github.com/eduardolat/pgbackweb/internal/util/strutil" + "github.com/eduardolat/pgbackweb/internal/util/timeutil" + "github.com/eduardolat/pgbackweb/internal/validate" + "github.com/eduardolat/pgbackweb/internal/view/web/component" + "github.com/eduardolat/pgbackweb/internal/view/web/htmx" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/maragudk/gomponents" + "github.com/maragudk/gomponents/html" +) + +type listResQueryData struct { + Execution uuid.UUID `query:"execution" validate:"omitempty,uuid"` + Database uuid.UUID `query:"database" validate:"omitempty,uuid"` + Page int `query:"page" validate:"required,min=1"` +} + +func (h *handlers) listRestorationsHandler(c echo.Context) error { + ctx := c.Request().Context() + + var queryData listResQueryData + if err := c.Bind(&queryData); err != nil { + return htmx.RespondToastError(c, err.Error()) + } + if err := validate.Struct(&queryData); err != nil { + return htmx.RespondToastError(c, err.Error()) + } + + pagination, restorations, err := h.servs.RestorationsService.PaginateRestorations( + ctx, restorations.PaginateRestorationsParams{ + ExecutionFilter: uuid.NullUUID{ + UUID: queryData.Execution, Valid: queryData.Execution != uuid.Nil, + }, + DatabaseFilter: uuid.NullUUID{ + UUID: queryData.Database, Valid: queryData.Database != uuid.Nil, + }, + Page: queryData.Page, + Limit: 20, + }, + ) + if err != nil { + return htmx.RespondToastError(c, err.Error()) + } + + return echoutil.RenderGomponent( + c, http.StatusOK, listRestorations(queryData, pagination, restorations), + ) +} + +func listRestorations( + queryData listResQueryData, + pagination paginateutil.PaginateResponse, + restorations []dbgen.RestorationsServicePaginateRestorationsRow, +) gomponents.Node { + trs := []gomponents.Node{} + for _, restoration := range restorations { + trs = append(trs, html.Tr( + html.Td( + html.Class("w-[50px]"), + html.Div( + html.Class("flex justify-start space-x-1"), + showRestorationButton(restoration), + ), + ), + html.Td(component.StatusBadge(restoration.Status)), + html.Td(component.SpanText(restoration.BackupName)), + html.Td(component.SpanText(func() string { + if restoration.DatabaseName.Valid { + return restoration.DatabaseName.String + } + return "Other database" + }())), + html.Td(component.SpanText(restoration.ExecutionID.String())), + html.Td(component.SpanText( + restoration.StartedAt.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty), + )), + html.Td( + gomponents.If( + restoration.FinishedAt.Valid, + component.SpanText( + restoration.FinishedAt.Time.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty), + ), + ), + ), + html.Td( + gomponents.If( + restoration.FinishedAt.Valid, + component.SpanText( + restoration.FinishedAt.Time.Sub(restoration.StartedAt).String(), + ), + ), + ), + )) + } + + if pagination.HasNextPage { + trs = append(trs, html.Tr( + htmx.HxGet(func() string { + url := "/dashboard/restorations/list" + url = strutil.AddQueryParamToUrl(url, "page", fmt.Sprintf("%d", pagination.NextPage)) + if queryData.Execution != uuid.Nil { + url = strutil.AddQueryParamToUrl(url, "execution", queryData.Execution.String()) + } + if queryData.Database != uuid.Nil { + url = strutil.AddQueryParamToUrl(url, "database", queryData.Database.String()) + } + return url + }()), + htmx.HxTrigger("intersect once"), + htmx.HxSwap("afterend"), + htmx.HxIndicator("#list-restorations-loading"), + )) + } + + return component.RenderableGroup(trs) +} diff --git a/internal/view/web/dashboard/restorations/router.go b/internal/view/web/dashboard/restorations/router.go new file mode 100644 index 0000000..9ad134e --- /dev/null +++ b/internal/view/web/dashboard/restorations/router.go @@ -0,0 +1,24 @@ +package restorations + +import ( + "github.com/eduardolat/pgbackweb/internal/service" + "github.com/eduardolat/pgbackweb/internal/view/middleware" + "github.com/labstack/echo/v4" +) + +type handlers struct { + servs *service.Service +} + +func newHandlers(servs *service.Service) *handlers { + return &handlers{servs: servs} +} + +func MountRouter( + parent *echo.Group, mids *middleware.Middleware, servs *service.Service, +) { + h := newHandlers(servs) + + parent.GET("", h.indexPageHandler) + parent.GET("/list", h.listRestorationsHandler) +} diff --git a/internal/view/web/dashboard/restorations/show_restoration.go b/internal/view/web/dashboard/restorations/show_restoration.go new file mode 100644 index 0000000..51d5048 --- /dev/null +++ b/internal/view/web/dashboard/restorations/show_restoration.go @@ -0,0 +1,95 @@ +package restorations + +import ( + lucide "github.com/eduardolat/gomponents-lucide" + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/eduardolat/pgbackweb/internal/util/timeutil" + "github.com/eduardolat/pgbackweb/internal/view/web/component" + "github.com/maragudk/gomponents" + "github.com/maragudk/gomponents/html" +) + +func showRestorationButton( + restoration dbgen.RestorationsServicePaginateRestorationsRow, +) gomponents.Node { + mo := component.Modal(component.ModalParams{ + Title: "Restoration details", + Size: component.SizeMd, + Content: []gomponents.Node{ + html.Div( + html.Class("overflow-x-auto"), + html.Table( + html.Class("table"), + html.Tr( + html.Th(component.SpanText("ID")), + html.Td(component.SpanText(restoration.ID.String())), + ), + html.Tr( + html.Th(component.SpanText("Status")), + html.Td(component.StatusBadge(restoration.Status)), + ), + html.Tr( + html.Th(component.SpanText("Backup")), + html.Td(component.SpanText(restoration.BackupName)), + ), + html.Tr( + html.Th(component.SpanText("Database")), + html.Td(component.SpanText(func() string { + if restoration.DatabaseName.Valid { + return restoration.DatabaseName.String + } + return "Other database" + }())), + ), + gomponents.If( + restoration.Message.Valid, + html.Tr( + html.Th(component.SpanText("Message")), + html.Td( + html.Class("break-all"), + component.SpanText(restoration.Message.String), + ), + ), + ), + html.Tr( + html.Th(component.SpanText("Started At")), + html.Td(component.SpanText( + restoration.StartedAt.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty), + )), + ), + gomponents.If( + restoration.FinishedAt.Valid, + html.Tr( + html.Th(component.SpanText("Finished At")), + html.Td(component.SpanText( + restoration.FinishedAt.Time.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty), + )), + ), + ), + gomponents.If( + restoration.FinishedAt.Valid, + html.Tr( + html.Th(component.SpanText("Took")), + html.Td(component.SpanText( + restoration.FinishedAt.Time.Sub(restoration.StartedAt).String(), + )), + ), + ), + ), + ), + }, + }) + + button := html.Button( + mo.OpenerAttr, + html.Class("btn btn-square btn-sm btn-ghost"), + lucide.Eye(), + ) + + return html.Div( + html.Class("inline-block tooltip tooltip-right"), + html.Data("tip", "Show details"), + mo.HTML, + button, + ) +} diff --git a/internal/view/web/dashboard/router.go b/internal/view/web/dashboard/router.go index ca4cf47..0acf3bf 100644 --- a/internal/view/web/dashboard/router.go +++ b/internal/view/web/dashboard/router.go @@ -9,6 +9,7 @@ import ( "github.com/eduardolat/pgbackweb/internal/view/web/dashboard/destinations" "github.com/eduardolat/pgbackweb/internal/view/web/dashboard/executions" "github.com/eduardolat/pgbackweb/internal/view/web/dashboard/profile" + "github.com/eduardolat/pgbackweb/internal/view/web/dashboard/restorations" "github.com/eduardolat/pgbackweb/internal/view/web/dashboard/summary" "github.com/labstack/echo/v4" ) @@ -21,6 +22,7 @@ func MountRouter( destinations.MountRouter(parent.Group("/destinations"), mids, servs) backups.MountRouter(parent.Group("/backups"), mids, servs) executions.MountRouter(parent.Group("/executions"), mids, servs) + restorations.MountRouter(parent.Group("/restorations"), mids, servs) profile.MountRouter(parent.Group("/profile"), mids, servs) about.MountRouter(parent.Group("/about"), mids, servs) } diff --git a/internal/view/web/dashboard/summary/index.go b/internal/view/web/dashboard/summary/index.go index 21f713a..2d093a4 100644 --- a/internal/view/web/dashboard/summary/index.go +++ b/internal/view/web/dashboard/summary/index.go @@ -32,15 +32,21 @@ func (h *handlers) indexPageHandler(c echo.Context) error { if err != nil { return c.String(http.StatusInternalServerError, err.Error()) } + restorationsQty, err := h.servs.RestorationsService.GetRestorationsQty(ctx) + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } return echoutil.RenderGomponent( c, http.StatusOK, - indexPage(databasesQty, destinationsQty, backupsQty, executionsQty), + indexPage( + databasesQty, destinationsQty, backupsQty, executionsQty, restorationsQty, + ), ) } func indexPage( - databasesQty, destinationsQty, backupsQty, executionsQty int64, + databasesQty, destinationsQty, backupsQty, executionsQty, restorationsQty int64, ) gomponents.Node { countCard := func(title string, count int64) gomponents.Node { return component.CardBox(component.CardBoxParams{ @@ -58,11 +64,12 @@ func indexPage( content := []gomponents.Node{ component.H1Text("Summary"), html.Div( - html.Class("mt-4 grid grid-cols-4 gap-4"), + html.Class("mt-4 grid grid-cols-5 gap-4"), countCard("Databases", databasesQty), countCard("Destinations", destinationsQty), countCard("Backups", backupsQty), countCard("Executions", executionsQty), + countCard("Restorations", restorationsQty), ), html.Div( alpine.XData("genericSlider(4)"), diff --git a/internal/view/web/layout/dashboard_aside.go b/internal/view/web/layout/dashboard_aside.go index 120206f..dc41cb4 100644 --- a/internal/view/web/layout/dashboard_aside.go +++ b/internal/view/web/layout/dashboard_aside.go @@ -77,6 +77,13 @@ func dashboardAside() gomponents.Node { false, ), + dashboardAsideItem( + lucide.ArchiveRestore, + "Restorations", + "/dashboard/restorations", + false, + ), + dashboardAsideItem( lucide.User, "Profile",