From 5f69043c13662bd4c61ea2992dd1929afdc6993c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?=
 <eduardo.devop@gmail.com>
Date: Sun, 4 Aug 2024 22:47:32 -0600
Subject: [PATCH] Add restore execution functionality to dashboard

---
 .../dashboard/executions/list_executions.go   |   1 +
 .../dashboard/executions/restore_execution.go | 246 ++++++++++++++++++
 .../view/web/dashboard/executions/router.go   |   2 +
 3 files changed, 249 insertions(+)
 create mode 100644 internal/view/web/dashboard/executions/restore_execution.go

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)
 }