Add backups router and handlers to dashboard

This commit is contained in:
Luis Eduardo Jeréz Girón
2024-07-24 02:31:26 -06:00
parent 3316190438
commit 5cf7da39c1
7 changed files with 814 additions and 0 deletions
@@ -0,0 +1,298 @@
package backups
import (
"net/http"
lucide "github.com/eduardolat/gomponents-lucide"
"github.com/eduardolat/pgbackweb/internal/database/dbgen"
"github.com/eduardolat/pgbackweb/internal/staticdata"
"github.com/eduardolat/pgbackweb/internal/util/echoutil"
"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"
)
func (h *handlers) createBackupHandler(c echo.Context) error {
ctx := c.Request().Context()
var formData struct {
DatabaseID uuid.UUID `form:"database_id" validate:"required,uuid"`
DestinationID uuid.UUID `form:"destination_id" validate:"required,uuid"`
Name string `form:"name" validate:"required"`
CronExpression string `form:"cron_expression" validate:"required"`
TimeZone string `form:"time_zone" validate:"required"`
IsActive string `form:"is_active" validate:"required,oneof=true false"`
DestDir string `form:"dest_dir" validate:"required"`
RetentionDays int16 `form:"retention_days" validate:"required"`
OptDataOnly string `form:"opt_data_only" validate:"required,oneof=true false"`
OptSchemaOnly string `form:"opt_schema_only" validate:"required,oneof=true false"`
OptClean string `form:"opt_clean" validate:"required,oneof=true false"`
OptIfExists string `form:"opt_if_exists" validate:"required,oneof=true false"`
OptCreate string `form:"opt_create" validate:"required,oneof=true false"`
OptNoComments string `form:"opt_no_comments" validate:"required,oneof=true false"`
}
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.BackupsService.CreateBackup(
ctx, dbgen.BackupsServiceCreateBackupParams{
DatabaseID: formData.DatabaseID,
DestinationID: formData.DestinationID,
Name: formData.Name,
CronExpression: formData.CronExpression,
TimeZone: formData.TimeZone,
IsActive: formData.IsActive == "true",
DestDir: formData.DestDir,
RetentionDays: formData.RetentionDays,
OptDataOnly: formData.OptDataOnly == "true",
OptSchemaOnly: formData.OptSchemaOnly == "true",
OptClean: formData.OptClean == "true",
OptIfExists: formData.OptIfExists == "true",
OptCreate: formData.OptCreate == "true",
OptNoComments: formData.OptNoComments == "true",
},
)
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
return htmx.RespondRedirect(c, "/dashboard/backups")
}
func (h *handlers) createBackupFormHandler(c echo.Context) error {
ctx := c.Request().Context()
databases, err := h.servs.DatabasesService.GetAllDatabases(ctx)
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
destinations, err := h.servs.DestinationsService.GetAllDestinations(ctx)
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
return echoutil.RenderGomponent(
c, http.StatusOK, createBackupForm(databases, destinations),
)
}
func createBackupForm(
databases []dbgen.DatabasesServiceGetAllDatabasesRow,
destinations []dbgen.DestinationsServiceGetAllDestinationsRow,
) gomponents.Node {
yesNoOptions := func() gomponents.Node {
return gomponents.Group([]gomponents.Node{
html.Option(html.Value("true"), gomponents.Text("Yes")),
html.Option(html.Value("false"), gomponents.Text("No"), html.Selected()),
})
}
return html.Form(
htmx.HxPost("/dashboard/backups"),
htmx.HxDisabledELT("find button"),
html.Class("space-y-2"),
component.SelectControl(component.SelectControlParams{
Name: "database_id",
Label: "Database",
Required: true,
Placeholder: "Select a database",
Children: []gomponents.Node{
component.GMap(
databases,
func(db dbgen.DatabasesServiceGetAllDatabasesRow) gomponents.Node {
return html.Option(html.Value(db.ID.String()), gomponents.Text(db.Name))
},
),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "destination_id",
Label: "Destination",
Required: true,
Placeholder: "Select a destination",
Children: []gomponents.Node{
component.GMap(
destinations,
func(dest dbgen.DestinationsServiceGetAllDestinationsRow) gomponents.Node {
return html.Option(html.Value(dest.ID.String()), gomponents.Text(dest.Name))
},
),
},
}),
component.InputControl(component.InputControlParams{
Name: "name",
Label: "Name",
Placeholder: "My backup",
Required: true,
Type: component.InputTypeText,
HelpText: "A name to easily identify the backup",
}),
component.InputControl(component.InputControlParams{
Name: "cron_expression",
Label: "Cron expression",
Placeholder: "* * * * *",
Required: true,
Type: component.InputTypeText,
HelpText: "The cron expression to schedule the backup",
}),
component.SelectControl(component.SelectControlParams{
Name: "time_zone",
Label: "Time zone",
Required: true,
Placeholder: "Select a time zone",
HelpText: "The time zone in which the cron expression will be evaluated",
Children: []gomponents.Node{
component.GMap(
staticdata.Timezones,
func(tz staticdata.Timezone) gomponents.Node {
return html.Option(html.Value(tz.TzCode), gomponents.Text(tz.Label))
},
),
},
}),
component.InputControl(component.InputControlParams{
Name: "dest_dir",
Label: "Destination directory",
Placeholder: "/path/to/backup",
Required: true,
Type: component.InputTypeText,
}),
component.InputControl(component.InputControlParams{
Name: "retention_days",
Label: "Retention days",
Placeholder: "30",
Required: true,
Type: component.InputTypeNumber,
HelpText: "The number of days to keep the backup. All backups older than this will be deleted",
Children: []gomponents.Node{
html.Min("0"),
html.Max("36500"),
html.Pattern("[0-9]+"),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "is_active",
Label: "Activate backup",
Required: true,
Children: []gomponents.Node{
html.Option(html.Value("true"), gomponents.Text("Yes")),
html.Option(html.Value("false"), gomponents.Text("No")),
},
}),
html.Div(
html.Class("grid grid-cols-2 gap-2"),
component.SelectControl(component.SelectControlParams{
Name: "opt_data_only",
Label: "--data-only",
Required: true,
Children: []gomponents.Node{
yesNoOptions(),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "opt_schema_only",
Label: "--schema-only",
Required: true,
Children: []gomponents.Node{
yesNoOptions(),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "opt_clean",
Label: "--clean",
Required: true,
Children: []gomponents.Node{
yesNoOptions(),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "opt_if_exists",
Label: "--if-exists",
Required: true,
Children: []gomponents.Node{
yesNoOptions(),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "opt_create",
Label: "--create",
Required: true,
Children: []gomponents.Node{
yesNoOptions(),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "opt_no_comments",
Label: "--no-comments",
Required: true,
Children: []gomponents.Node{
yesNoOptions(),
},
}),
),
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("Save"),
lucide.Save(),
),
),
)
}
func createBackupButton() gomponents.Node {
mo := component.Modal(component.ModalParams{
Size: component.SizeLg,
Title: "Create backup",
Content: []gomponents.Node{
html.Div(
htmx.HxGet("/dashboard/backups/create-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-primary"),
component.SpanText("Create backup"),
lucide.Plus(),
)
return html.Div(
html.Class("inline-block"),
mo.HTML,
button,
)
}
@@ -0,0 +1,38 @@
package backups
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) deleteBackupHandler(c echo.Context) error {
ctx := c.Request().Context()
backupID, err := uuid.Parse(c.Param("backupID"))
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
if err = h.servs.BackupsService.DeleteBackup(ctx, backupID); err != nil {
return htmx.RespondToastError(c, err.Error())
}
return htmx.RespondRefresh(c)
}
func deleteBackupButton(backupID uuid.UUID) gomponents.Node {
return html.Div(
html.Class("inline-block tooltip tooltip-right"),
html.Data("tip", "Delete backup"),
html.Button(
htmx.HxDelete("/dashboard/backups/"+backupID.String()),
htmx.HxConfirm("Are you sure you want to delete this backup?"),
html.Class("btn btn-error btn-square btn-sm btn-ghost"),
lucide.Trash(),
),
)
}
@@ -0,0 +1,265 @@
package backups
import (
"database/sql"
"fmt"
lucide "github.com/eduardolat/gomponents-lucide"
"github.com/eduardolat/pgbackweb/internal/database/dbgen"
"github.com/eduardolat/pgbackweb/internal/staticdata"
"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"
)
func (h *handlers) editBackupHandler(c echo.Context) error {
ctx := c.Request().Context()
backupID, err := uuid.Parse(c.Param("backupID"))
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
var formData struct {
Name string `form:"name" validate:"required"`
CronExpression string `form:"cron_expression" validate:"required"`
TimeZone string `form:"time_zone" validate:"required"`
IsActive string `form:"is_active" validate:"required,oneof=true false"`
DestDir string `form:"dest_dir" validate:"required"`
RetentionDays int16 `form:"retention_days" validate:"required"`
OptDataOnly string `form:"opt_data_only" validate:"required,oneof=true false"`
OptSchemaOnly string `form:"opt_schema_only" validate:"required,oneof=true false"`
OptClean string `form:"opt_clean" validate:"required,oneof=true false"`
OptIfExists string `form:"opt_if_exists" validate:"required,oneof=true false"`
OptCreate string `form:"opt_create" validate:"required,oneof=true false"`
OptNoComments string `form:"opt_no_comments" validate:"required,oneof=true false"`
}
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.BackupsService.UpdateBackup(
ctx, dbgen.BackupsServiceUpdateBackupParams{
ID: backupID,
Name: sql.NullString{String: formData.Name, Valid: true},
CronExpression: sql.NullString{String: formData.CronExpression, Valid: true},
TimeZone: sql.NullString{String: formData.TimeZone, Valid: true},
IsActive: sql.NullBool{Bool: formData.IsActive == "true", Valid: true},
DestDir: sql.NullString{String: formData.DestDir, Valid: true},
RetentionDays: sql.NullInt16{Int16: formData.RetentionDays, Valid: true},
OptDataOnly: sql.NullBool{Bool: formData.OptDataOnly == "true", Valid: true},
OptSchemaOnly: sql.NullBool{Bool: formData.OptSchemaOnly == "true", Valid: true},
OptClean: sql.NullBool{Bool: formData.OptClean == "true", Valid: true},
OptIfExists: sql.NullBool{Bool: formData.OptIfExists == "true", Valid: true},
OptCreate: sql.NullBool{Bool: formData.OptCreate == "true", Valid: true},
OptNoComments: sql.NullBool{Bool: formData.OptNoComments == "true", Valid: true},
},
)
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
return htmx.RespondToastSuccess(c, "Backup updated")
}
func editBackupButton(backup dbgen.BackupsServicePaginateBackupsRow) gomponents.Node {
yesNoOptions := func(value bool) gomponents.Node {
return gomponents.Group([]gomponents.Node{
html.Option(
html.Value("true"),
gomponents.Text("Yes"),
gomponents.If(value, html.Selected()),
),
html.Option(
html.Value("false"),
gomponents.Text("No"),
gomponents.If(!value, html.Selected()),
),
})
}
mo := component.Modal(component.ModalParams{
Size: component.SizeLg,
Title: "Edit backup",
Content: []gomponents.Node{
html.Form(
htmx.HxPost("/dashboard/backups/"+backup.ID.String()+"/edit"),
htmx.HxDisabledELT("find button"),
html.Class("space-y-2"),
component.InputControl(component.InputControlParams{
Name: "name",
Label: "Name",
Placeholder: "My backup",
Required: true,
Type: component.InputTypeText,
HelpText: "A name to easily identify the backup",
Children: []gomponents.Node{
html.Value(backup.Name),
},
}),
component.InputControl(component.InputControlParams{
Name: "cron_expression",
Label: "Cron expression",
Placeholder: "* * * * *",
Required: true,
Type: component.InputTypeText,
HelpText: "The cron expression to schedule the backup",
Children: []gomponents.Node{
html.Value(backup.CronExpression),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "time_zone",
Label: "Time zone",
Required: true,
Placeholder: "Select a time zone",
HelpText: "The time zone in which the cron expression will be evaluated",
Children: []gomponents.Node{
component.GMap(
staticdata.Timezones,
func(tz staticdata.Timezone) gomponents.Node {
return html.Option(
html.Value(tz.TzCode),
gomponents.Text(tz.Label),
gomponents.If(
tz.TzCode == backup.TimeZone,
html.Selected(),
),
)
},
),
},
}),
component.InputControl(component.InputControlParams{
Name: "dest_dir",
Label: "Destination directory",
Placeholder: "/path/to/backup",
Required: true,
Type: component.InputTypeText,
Children: []gomponents.Node{
html.Value(backup.DestDir),
},
}),
component.InputControl(component.InputControlParams{
Name: "retention_days",
Label: "Retention days",
Placeholder: "30",
Required: true,
Type: component.InputTypeNumber,
HelpText: "The number of days to keep the backup. All backups older than this will be deleted",
Children: []gomponents.Node{
html.Min("0"),
html.Max("36500"),
html.Pattern("[0-9]+"),
html.Value(fmt.Sprintf("%d", backup.RetentionDays)),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "is_active",
Label: "Activate backup",
Required: true,
Children: []gomponents.Node{
yesNoOptions(backup.IsActive),
},
}),
html.Div(
html.Class("grid grid-cols-2 gap-2"),
component.SelectControl(component.SelectControlParams{
Name: "opt_data_only",
Label: "--data-only",
Required: true,
Children: []gomponents.Node{
yesNoOptions(backup.OptDataOnly),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "opt_schema_only",
Label: "--schema-only",
Required: true,
Children: []gomponents.Node{
yesNoOptions(backup.OptSchemaOnly),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "opt_clean",
Label: "--clean",
Required: true,
Children: []gomponents.Node{
yesNoOptions(backup.OptClean),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "opt_if_exists",
Label: "--if-exists",
Required: true,
Children: []gomponents.Node{
yesNoOptions(backup.OptIfExists),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "opt_create",
Label: "--create",
Required: true,
Children: []gomponents.Node{
yesNoOptions(backup.OptCreate),
},
}),
component.SelectControl(component.SelectControlParams{
Name: "opt_no_comments",
Label: "--no-comments",
Required: true,
Children: []gomponents.Node{
yesNoOptions(backup.OptNoComments),
},
}),
),
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("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 backup"),
button,
),
)
}
@@ -0,0 +1,66 @@
package backups
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("Backups"),
createBackupButton(),
),
html.Div(
html.Div(
html.Class("mt-4 overflow-x-auto"),
html.Table(
html.Class("table text-nowrap"),
html.THead(
html.Tr(
html.Th(),
html.Th(component.SpanText("Name")),
html.Th(component.SpanText("Database")),
html.Th(component.SpanText("Destination")),
html.Th(component.SpanText("Schedule")),
html.Th(component.SpanText("Retention")),
html.Th(component.SpanText("--data-only")),
html.Th(component.SpanText("--schema-only")),
html.Th(component.SpanText("--clean")),
html.Th(component.SpanText("--if-exists")),
html.Th(component.SpanText("--create")),
html.Th(component.SpanText("--no-comments")),
html.Th(component.SpanText("Created at")),
),
),
html.TBody(
htmx.HxGet("/dashboard/backups/list?page=1"),
htmx.HxTrigger("load"),
htmx.HxIndicator("#list-backups-loading"),
),
),
),
html.Div(
html.Class("flex justify-center mt-4"),
component.HxLoadingLg("list-backups-loading"),
),
),
}
return layout.Dashboard(layout.DashboardParams{
Title: "Backups",
Body: content,
})
}
@@ -0,0 +1,117 @@
package backups
import (
"fmt"
"net/http"
"github.com/eduardolat/pgbackweb/internal/database/dbgen"
"github.com/eduardolat/pgbackweb/internal/service/backups"
"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) listBackupsHandler(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, backups, err := h.servs.BackupsService.PaginateBackups(
ctx, backups.PaginateBackupsParams{
Page: formData.Page,
Limit: 8,
},
)
if err != nil {
return htmx.RespondToastError(c, err.Error())
}
return echoutil.RenderGomponent(
c, http.StatusOK, listBackups(pagination, backups),
)
}
func listBackups(
pagination paginateutil.PaginateResponse,
backups []dbgen.BackupsServicePaginateBackupsRow,
) gomponents.Node {
yesNoSpan := func(b bool) gomponents.Node {
if b {
return component.SpanText("Yes")
}
return component.SpanText("No")
}
trs := []gomponents.Node{}
for _, backup := range backups {
trs = append(trs, html.Tr(
html.Td(
html.Class("w-[40px]"),
html.Div(
html.Class("flex justify-start space-x-1"),
editBackupButton(backup),
deleteBackupButton(backup.ID),
),
),
html.Td(
html.Div(
html.Class("flex items-center space-x-2"),
html.Div(
html.Class("tooltip tooltip-right"),
gomponents.If(backup.IsActive, html.Data("tip", "Active")),
gomponents.If(!backup.IsActive, html.Data("tip", "Inactive")),
component.EnabledPing(backup.IsActive),
),
component.SpanText(backup.Name),
),
),
html.Td(component.SpanText(backup.DatabaseName)),
html.Td(component.SpanText(backup.DestinationName)),
html.Td(
html.Class("font-mono"),
html.Div(
html.Class("flex flex-col items-start space-y-1"),
component.SpanText(backup.CronExpression),
component.SpanText(backup.TimeZone),
),
),
html.Td(component.SpanText(fmt.Sprintf("%d days", backup.RetentionDays))),
html.Td(yesNoSpan(backup.OptDataOnly)),
html.Td(yesNoSpan(backup.OptSchemaOnly)),
html.Td(yesNoSpan(backup.OptClean)),
html.Td(yesNoSpan(backup.OptIfExists)),
html.Td(yesNoSpan(backup.OptCreate)),
html.Td(yesNoSpan(backup.OptNoComments)),
html.Td(component.SpanText(
backup.CreatedAt.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty),
)),
))
}
if pagination.HasNextPage {
trs = append(trs, html.Tr(
htmx.HxGet(fmt.Sprintf(
"/dashboard/backups/list?page=%d", pagination.NextPage,
)),
htmx.HxTrigger("intersect once"),
htmx.HxSwap("afterend"),
htmx.HxIndicator("#list-backups-loading"),
))
}
return component.RenderableGroup(trs)
}
@@ -0,0 +1,28 @@
package backups
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.listBackupsHandler)
parent.GET("/create-form", h.createBackupFormHandler)
parent.POST("", h.createBackupHandler)
parent.DELETE("/:backupID", h.deleteBackupHandler)
parent.POST("/:backupID/edit", h.editBackupHandler)
}
+2
View File
@@ -4,6 +4,7 @@ import (
"github.com/eduardolat/pgbackweb/internal/service"
"github.com/eduardolat/pgbackweb/internal/view/middleware"
"github.com/eduardolat/pgbackweb/internal/view/web/dashboard/about"
"github.com/eduardolat/pgbackweb/internal/view/web/dashboard/backups"
"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"
@@ -17,6 +18,7 @@ func MountRouter(
summary.MountRouter(parent.Group(""), mids, servs)
databases.MountRouter(parent.Group("/databases"), mids, servs)
destinations.MountRouter(parent.Group("/destinations"), mids, servs)
backups.MountRouter(parent.Group("/backups"), mids, servs)
profile.MountRouter(parent.Group("/profile"), mids, servs)
about.MountRouter(parent.Group("/about"), mids, servs)
}