Add destinations functionality to dashboard

This commit is contained in:
Luis Eduardo Jeréz Girón
2024-07-24 00:30:45 -06:00
parent ca68b8358a
commit 3281f32896
7 changed files with 640 additions and 0 deletions
@@ -0,0 +1,180 @@
package destinations
import (
lucide "github.com/eduardolat/gomponents-lucide"
"github.com/eduardolat/pgbackweb/internal/database/dbgen"
"github.com/eduardolat/pgbackweb/internal/validate"
"github.com/eduardolat/pgbackweb/internal/view/web/component"
"github.com/eduardolat/pgbackweb/internal/view/web/htmx"
"github.com/labstack/echo/v4"
"github.com/maragudk/gomponents"
"github.com/maragudk/gomponents/html"
)
type createDestinationDTO struct {
Name string `form:"name" validate:"required"`
BucketName string `form:"bucket_name" validate:"required"`
AccessKey string `form:"access_key" validate:"required"`
SecretKey string `form:"secret_key" validate:"required"`
Region string `form:"region" validate:"required"`
Endpoint string `form:"endpoint" validate:"required"`
}
func (h *handlers) testDestinationHandler(c echo.Context) error {
var formData createDestinationDTO
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())
}
err := h.servs.DestinationsService.TestDestination(
formData.AccessKey, formData.SecretKey, formData.Region, formData.Endpoint,
formData.BucketName,
)
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
return htmx.RespondToastSuccess(c, "Connection successful")
}
func (h *handlers) createDestinationHandler(c echo.Context) error {
ctx := c.Request().Context()
var formData createDestinationDTO
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())
}
_, err := h.servs.DestinationsService.CreateDestination(
ctx, dbgen.DestinationsServiceCreateDestinationParams{
Name: formData.Name,
AccessKey: formData.AccessKey,
SecretKey: formData.SecretKey,
Region: formData.Region,
Endpoint: formData.Endpoint,
BucketName: formData.BucketName,
},
)
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
return htmx.RespondRedirect(c, "/dashboard/destinations")
}
func createDestinationButton() gomponents.Node {
htmxAttributes := func(url string) gomponents.Node {
return gomponents.Group([]gomponents.Node{
htmx.HxPost(url),
htmx.HxInclude("#create-destination-form"),
htmx.HxDisabledELT(".create-destination-btn"),
htmx.HxIndicator("#create-destination-loading"),
htmx.HxValidate("true"),
})
}
mo := component.Modal(component.ModalParams{
Size: component.SizeMd,
Title: "Create destination",
Content: []gomponents.Node{
html.Form(
html.ID("create-destination-form"),
html.Class("space-y-2"),
component.InputControl(component.InputControlParams{
Name: "name",
Label: "Name",
Placeholder: "My destination",
Required: true,
Type: component.InputTypeText,
HelpText: "A name to easily identify the destination",
}),
component.InputControl(component.InputControlParams{
Name: "bucket_name",
Label: "Bucket name",
Placeholder: "my-bucket",
Required: true,
Type: component.InputTypeText,
}),
component.InputControl(component.InputControlParams{
Name: "endpoint",
Label: "Endpoint",
Placeholder: "s3-us-west-1.amazonaws.com",
Required: true,
Type: component.InputTypeText,
}),
component.InputControl(component.InputControlParams{
Name: "region",
Label: "Region",
Placeholder: "us-west-1",
Required: true,
Type: component.InputTypeText,
}),
component.InputControl(component.InputControlParams{
Name: "access_key",
Label: "Access key",
Placeholder: "Access key",
Required: true,
Type: component.InputTypeText,
HelpText: "It will be stored securely using PGP encryption.",
}),
component.InputControl(component.InputControlParams{
Name: "secret_key",
Label: "Secret key",
Placeholder: "Secret key",
Required: true,
Type: component.InputTypeText,
HelpText: "It will be stored securely using PGP encryption.",
}),
),
html.Div(
html.Class("flex justify-between items-center pt-4"),
html.Div(
html.Button(
htmxAttributes("/dashboard/destinations/test"),
html.Class("create-destination-btn btn btn-neutral btn-outline"),
html.Type("button"),
component.SpanText("Test connection"),
lucide.PlugZap(),
),
),
html.Div(
html.Class("flex justify-end items-center space-x-2"),
component.HxLoadingMd("create-destination-loading"),
html.Button(
htmxAttributes("/dashboard/destinations"),
html.Class("create-destination-btn btn btn-primary"),
html.Type("button"),
component.SpanText("Save"),
lucide.Save(),
),
),
),
},
})
button := html.Button(
mo.OpenerAttr,
html.Class("btn btn-primary"),
component.SpanText("Create destination"),
lucide.Plus(),
)
return html.Div(
html.Class("inline-block"),
mo.HTML,
button,
)
}
@@ -0,0 +1,39 @@
package destinations
import (
lucide "github.com/eduardolat/gomponents-lucide"
"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) deleteDestinationHandler(c echo.Context) error {
ctx := c.Request().Context()
destinationID, err := uuid.Parse(c.Param("destinationID"))
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
err = h.servs.DestinationsService.DeleteDestination(ctx, destinationID)
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
return htmx.RespondRefresh(c)
}
func deleteDestinationButton(destinationID uuid.UUID) gomponents.Node {
return html.Div(
html.Class("inline-block tooltip tooltip-right"),
html.Data("tip", "Delete destination"),
html.Button(
htmx.HxDelete("/dashboard/destinations/"+destinationID.String()),
htmx.HxConfirm("Are you sure you want to delete this destination?"),
html.Class("btn btn-error btn-square btn-sm btn-ghost"),
lucide.Trash(),
),
)
}
@@ -0,0 +1,195 @@
package destinations
import (
"database/sql"
lucide "github.com/eduardolat/gomponents-lucide"
"github.com/eduardolat/pgbackweb/internal/database/dbgen"
"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/components"
"github.com/maragudk/gomponents/html"
)
func (h *handlers) editDestinationHandler(c echo.Context) error {
ctx := c.Request().Context()
destinationID, err := uuid.Parse(c.Param("destinationID"))
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
var formData createDestinationDTO
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())
}
_, err = h.servs.DestinationsService.UpdateDestination(
ctx, dbgen.DestinationsServiceUpdateDestinationParams{
ID: destinationID,
Name: sql.NullString{String: formData.Name, Valid: true},
BucketName: sql.NullString{String: formData.BucketName, Valid: true},
Region: sql.NullString{String: formData.Region, Valid: true},
Endpoint: sql.NullString{String: formData.Endpoint, Valid: true},
AccessKey: sql.NullString{String: formData.AccessKey, Valid: true},
SecretKey: sql.NullString{String: formData.SecretKey, Valid: true},
},
)
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
return htmx.RespondToastSuccess(c, "Destination updated")
}
func editDestinationButton(
destination dbgen.DestinationsServicePaginateDestinationsRow,
) gomponents.Node {
idPref := "edit-destination-" + destination.ID.String()
formID := idPref + "-form"
btnClass := idPref + "-btn"
loadingID := idPref + "-loading"
htmxAttributes := func(url string) gomponents.Node {
return gomponents.Group([]gomponents.Node{
htmx.HxPost(url),
htmx.HxInclude("#" + formID),
htmx.HxDisabledELT("." + btnClass),
htmx.HxIndicator("#" + loadingID),
htmx.HxValidate("true"),
})
}
mo := component.Modal(component.ModalParams{
Size: component.SizeMd,
Title: "Edit destination",
Content: []gomponents.Node{
html.Form(
html.ID(formID),
html.Class("space-y-2"),
component.InputControl(component.InputControlParams{
Name: "name",
Label: "Name",
Placeholder: "My destination",
Required: true,
Type: component.InputTypeText,
HelpText: "A name to easily identify the destination",
Children: []gomponents.Node{
html.Value(destination.Name),
},
}),
component.InputControl(component.InputControlParams{
Name: "bucket_name",
Label: "Bucket name",
Placeholder: "my-bucket",
Required: true,
Type: component.InputTypeText,
Children: []gomponents.Node{
html.Value(destination.BucketName),
},
}),
component.InputControl(component.InputControlParams{
Name: "endpoint",
Label: "Endpoint",
Placeholder: "s3-us-west-1.amazonaws.com",
Required: true,
Type: component.InputTypeText,
Children: []gomponents.Node{
html.Value(destination.Endpoint),
},
}),
component.InputControl(component.InputControlParams{
Name: "region",
Label: "Region",
Placeholder: "us-west-1",
Required: true,
Type: component.InputTypeText,
Children: []gomponents.Node{
html.Value(destination.Region),
},
}),
component.InputControl(component.InputControlParams{
Name: "access_key",
Label: "Access key",
Placeholder: "Access key",
Required: true,
Type: component.InputTypeText,
HelpText: "It will be stored securely using PGP encryption.",
Children: []gomponents.Node{
html.Value(destination.DecryptedAccessKey),
},
}),
component.InputControl(component.InputControlParams{
Name: "secret_key",
Label: "Secret key",
Placeholder: "Secret key",
Required: true,
Type: component.InputTypeText,
HelpText: "It will be stored securely using PGP encryption.",
Children: []gomponents.Node{
html.Value(destination.DecryptedSecretKey),
},
}),
),
html.Div(
html.Class("flex justify-between items-center pt-4"),
html.Div(
html.Button(
htmxAttributes("/dashboard/destinations/test"),
components.Classes{
btnClass: true,
"btn btn-neutral btn-outline": true,
},
html.Type("button"),
component.SpanText("Test connection"),
lucide.PlugZap(),
),
),
html.Div(
html.Class("flex justify-end items-center space-x-2"),
component.HxLoadingMd(loadingID),
html.Button(
htmxAttributes("/dashboard/destinations/"+destination.ID.String()+"/edit"),
components.Classes{
btnClass: true,
"btn btn-primary": true,
},
html.Type("button"),
component.SpanText("Save"),
lucide.Save(),
),
),
),
},
})
button := html.Button(
mo.OpenerAttr,
html.Class("btn btn-neutral btn-sm btn-square btn-ghost"),
lucide.Pencil(),
)
return html.Div(
html.Class("inline-block"),
mo.HTML,
html.Div(
html.Class("inline-block tooltip tooltip-right"),
html.Data("tip", "Edit destination"),
button,
),
)
}
@@ -0,0 +1,61 @@
package destinations
import (
"net/http"
"github.com/eduardolat/pgbackweb/internal/util/echoutil"
"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/labstack/echo/v4"
"github.com/maragudk/gomponents"
"github.com/maragudk/gomponents/html"
)
func (h *handlers) indexPageHandler(c echo.Context) error {
return echoutil.RenderGomponent(c, http.StatusOK, indexPage())
}
func indexPage() gomponents.Node {
content := []gomponents.Node{
html.Div(
html.Class("flex justify-between items-start"),
component.H1Text("Destinations"),
createDestinationButton(),
),
html.Div(
html.Div(
html.Class("mt-4 overflow-x-auto"),
html.Table(
html.Class("table"),
html.THead(
html.Tr(
html.Th(),
html.Th(component.SpanText("Name")),
html.Th(component.SpanText("Bucket name")),
html.Th(component.SpanText("Endpoint")),
html.Th(component.SpanText("Region")),
html.Th(component.SpanText("Access key")),
html.Th(component.SpanText("Secret key")),
html.Th(component.SpanText("Created at")),
),
),
html.TBody(
htmx.HxGet("/dashboard/destinations/list?page=1"),
htmx.HxTrigger("load"),
htmx.HxIndicator("#list-destinations-loading"),
),
),
),
html.Div(
html.Class("flex justify-center mt-4"),
component.HxLoadingLg("list-destinations-loading"),
),
),
}
return layout.Dashboard(layout.DashboardParams{
Title: "Destinations",
Body: content,
})
}
@@ -0,0 +1,135 @@
package destinations
import (
"fmt"
"net/http"
lucide "github.com/eduardolat/gomponents-lucide"
"github.com/eduardolat/pgbackweb/internal/database/dbgen"
"github.com/eduardolat/pgbackweb/internal/service/destinations"
"github.com/eduardolat/pgbackweb/internal/util/echoutil"
"github.com/eduardolat/pgbackweb/internal/util/paginateutil"
"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/labstack/echo/v4"
"github.com/maragudk/gomponents"
"github.com/maragudk/gomponents/html"
)
func (h *handlers) listDestinationsHandler(c echo.Context) error {
ctx := c.Request().Context()
var formData struct {
Page int `query:"page" validate:"required,min=1"`
}
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())
}
pagination, destinations, err := h.servs.DestinationsService.PaginateDestinations(
ctx, destinations.PaginateDestinationsParams{
Page: formData.Page,
Limit: 8,
},
)
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
return echoutil.RenderGomponent(
c, http.StatusOK, listDestinations(pagination, destinations),
)
}
func listDestinations(
pagination paginateutil.PaginateResponse,
destinations []dbgen.DestinationsServicePaginateDestinationsRow,
) gomponents.Node {
trs := []gomponents.Node{}
for _, destination := range destinations {
trs = append(trs, html.Tr(
html.Td(
html.Class("w-[40px]"),
html.Div(
html.Class("flex justify-start space-x-1"),
editDestinationButton(destination),
html.Form(
html.Class("inline-block tooltip tooltip-right"),
html.Data("tip", "Test connection"),
htmx.HxPost("/dashboard/destinations/test"),
html.Input(
html.Type("hidden"),
html.Name("name"),
html.Value(destination.Name),
),
html.Input(
html.Type("hidden"),
html.Name("bucket_name"),
html.Value(destination.BucketName),
),
html.Input(
html.Type("hidden"),
html.Name("endpoint"),
html.Value(destination.Endpoint),
),
html.Input(
html.Type("hidden"),
html.Name("region"),
html.Value(destination.Region),
),
html.Input(
html.Type("hidden"),
html.Name("access_key"),
html.Value(destination.DecryptedAccessKey),
),
html.Input(
html.Type("hidden"),
html.Name("secret_key"),
html.Value(destination.DecryptedSecretKey),
),
html.Button(
html.Class("btn btn-neutral btn-square btn-ghost btn-sm"),
lucide.PlugZap(),
),
),
deleteDestinationButton(destination.ID),
),
),
html.Td(component.SpanText(destination.Name)),
html.Td(component.SpanText(destination.BucketName)),
html.Td(component.SpanText(destination.Endpoint)),
html.Td(component.SpanText(destination.Region)),
html.Td(
html.Class("space-x-1"),
component.CopyButtonSm(destination.DecryptedAccessKey),
component.SpanText("**********"),
),
html.Td(
html.Class("space-x-1"),
component.CopyButtonSm(destination.DecryptedSecretKey),
component.SpanText("**********"),
),
html.Td(component.SpanText(
destination.CreatedAt.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty),
)),
))
}
if pagination.HasNextPage {
trs = append(trs, html.Tr(
htmx.HxGet(fmt.Sprintf(
"/dashboard/destinations/list?page=%d", pagination.NextPage,
)),
htmx.HxTrigger("intersect once"),
htmx.HxSwap("afterend"),
htmx.HxIndicator("#list-destinations-loading"),
))
}
return component.RenderableGroup(trs)
}
@@ -0,0 +1,28 @@
package destinations
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.listDestinationsHandler)
parent.POST("", h.createDestinationHandler)
parent.POST("/test", h.testDestinationHandler)
parent.DELETE("/:destinationID", h.deleteDestinationHandler)
parent.POST("/:destinationID/edit", h.editDestinationHandler)
}
+2
View File
@@ -5,6 +5,7 @@ import (
"github.com/eduardolat/pgbackweb/internal/view/middleware"
"github.com/eduardolat/pgbackweb/internal/view/web/dashboard/about"
"github.com/eduardolat/pgbackweb/internal/view/web/dashboard/databases"
"github.com/eduardolat/pgbackweb/internal/view/web/dashboard/destinations"
"github.com/eduardolat/pgbackweb/internal/view/web/dashboard/profile"
"github.com/eduardolat/pgbackweb/internal/view/web/dashboard/summary"
"github.com/labstack/echo/v4"
@@ -15,6 +16,7 @@ func MountRouter(
) {
summary.MountRouter(parent.Group(""), mids, servs)
databases.MountRouter(parent.Group("/databases"), mids, servs)
destinations.MountRouter(parent.Group("/destinations"), mids, servs)
profile.MountRouter(parent.Group("/profile"), mids, servs)
about.MountRouter(parent.Group("/about"), mids, servs)
}