From e28916212b4bce6101df53a8a0f4f09c94cc8c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 10:52:01 -0600 Subject: [PATCH 01/24] Create backups directory in Dockerfiles --- docker/Dockerfile | 6 ++++-- docker/Dockerfile.cicd | 6 ++++-- docker/Dockerfile.dev | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index c2e923d..46df87d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -76,9 +76,11 @@ RUN wget https://github.com/golangci/golangci-lint/releases/download/v1.59.1/gol mv ./golangci-lint-1.59.1-linux-amd64/golangci-lint /usr/local/bin/golangci-lint && \ chmod 777 /usr/local/bin/golangci-lint -# Delete the temporary directory and go to the app directory +# Go to the app dir, delete the temporary dir and create backups dir WORKDIR /app -RUN rm -rf /app/temp +RUN rm -rf /app/temp && \ + mkdir /backups && \ + chmod 777 /backups ############## # START HERE # diff --git a/docker/Dockerfile.cicd b/docker/Dockerfile.cicd index aac8283..1a778fa 100644 --- a/docker/Dockerfile.cicd +++ b/docker/Dockerfile.cicd @@ -76,9 +76,11 @@ RUN wget https://github.com/golangci/golangci-lint/releases/download/v1.59.1/gol mv ./golangci-lint-1.59.1-linux-amd64/golangci-lint /usr/local/bin/golangci-lint && \ chmod 777 /usr/local/bin/golangci-lint -# Delete the temporary directory and go to the app directory +# Go to the app dir, delete the temporary dir and create backups dir WORKDIR /app -RUN rm -rf /app/temp +RUN rm -rf /app/temp && \ + mkdir /backups && \ + chmod 777 /backups ############## # START HERE # diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 033422f..826a37f 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -76,9 +76,11 @@ RUN wget https://github.com/golangci/golangci-lint/releases/download/v1.59.1/gol mv ./golangci-lint-1.59.1-linux-amd64/golangci-lint /usr/local/bin/golangci-lint && \ chmod 777 /usr/local/bin/golangci-lint -# Delete the temporary directory and go to the app directory +# Go to the app dir, delete the temporary dir and create backups dir WORKDIR /app -RUN rm -rf /app/temp +RUN rm -rf /app/temp && \ + mkdir /backups && \ + chmod 777 /backups ############## # START HERE # From 2d2f8fa81dbb093baaef13e20d56645734df8c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 11:07:35 -0600 Subject: [PATCH 02/24] Refactor storage to add local storage --- internal/integration/integration.go | 12 ++-- internal/integration/storage/local.go | 63 +++++++++++++++++++ internal/integration/{s3 => storage}/s3.go | 48 +++++++++----- internal/integration/storage/storage.go | 7 +++ .../service/destinations/test_destination.go | 2 +- .../executions/get_execution_download_link.go | 2 +- internal/service/executions/run_execution.go | 4 +- .../executions/soft_delete_execution.go | 2 +- 8 files changed, 114 insertions(+), 26 deletions(-) create mode 100644 internal/integration/storage/local.go rename internal/integration/{s3 => storage}/s3.go (76%) create mode 100644 internal/integration/storage/storage.go diff --git a/internal/integration/integration.go b/internal/integration/integration.go index 67b6922..e9b49d1 100644 --- a/internal/integration/integration.go +++ b/internal/integration/integration.go @@ -2,20 +2,20 @@ package integration import ( "github.com/eduardolat/pgbackweb/internal/integration/postgres" - "github.com/eduardolat/pgbackweb/internal/integration/s3" + "github.com/eduardolat/pgbackweb/internal/integration/storage" ) type Integration struct { - PGClient *postgres.Client - S3Client *s3.Client + PGClient *postgres.Client + StorageClient *storage.Client } func New() *Integration { pgClient := postgres.New() - s3Client := s3.New() + storageClient := storage.New() return &Integration{ - PGClient: pgClient, - S3Client: s3Client, + PGClient: pgClient, + StorageClient: storageClient, } } diff --git a/internal/integration/storage/local.go b/internal/integration/storage/local.go new file mode 100644 index 0000000..3c28920 --- /dev/null +++ b/internal/integration/storage/local.go @@ -0,0 +1,63 @@ +package storage + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +const ( + localBackupsDir string = "/backups" +) + +// LocalUpload Creates a new file using the provided path and reader relative +// to the local backups directory. +func (Client) LocalUpload(relativePath string, fileReader io.Reader) error { + fullPath := filepath.Join(localBackupsDir, relativePath) + dir := filepath.Dir(fullPath) + + err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm) + if err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + file, err := os.Create(fullPath) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", fullPath, err) + } + defer file.Close() + + _, err = io.Copy(file, fileReader) + if err != nil { + return fmt.Errorf("failed to write file %s: %w", fullPath, err) + } + + return nil +} + +// LocalDelete Deletes a file using the provided path relative to the local +// backups directory. +func (Client) LocalDelete(relativePath string) error { + fullPath := filepath.Join(localBackupsDir, relativePath) + + err := os.Remove(fullPath) + if err != nil { + return fmt.Errorf("failed to delete file %s: %w", fullPath, err) + } + + return nil +} + +// LocalReadFile Reads a file using the provided path relative to the local +// backups directory. +func (Client) LocalReadFile(relativePath string) (io.ReadCloser, error) { + fullPath := filepath.Join(localBackupsDir, relativePath) + + file, err := os.Open(fullPath) + if err != nil { + return nil, fmt.Errorf("failed to open file %s: %w", fullPath, err) + } + + return file, nil +} diff --git a/internal/integration/s3/s3.go b/internal/integration/storage/s3.go similarity index 76% rename from internal/integration/s3/s3.go rename to internal/integration/storage/s3.go index ecec6a2..841ea0a 100644 --- a/internal/integration/s3/s3.go +++ b/internal/integration/storage/s3.go @@ -1,4 +1,4 @@ -package s3 +package storage import ( "fmt" @@ -13,12 +13,6 @@ import ( "github.com/eduardolat/pgbackweb/internal/util/strutil" ) -type Client struct{} - -func New() *Client { - return &Client{} -} - // createS3Client creates a new S3 client func createS3Client( accessKey, secretKey, region, endpoint string, @@ -36,8 +30,8 @@ func createS3Client( return s3.New(sess), nil } -// Ping tests the connection to S3 -func (Client) Ping( +// S3Ping tests the connection to S3 +func (Client) S3Ping( accessKey, secretKey, region, endpoint, bucketName string, ) error { s3Client, err := createS3Client( @@ -57,8 +51,8 @@ func (Client) Ping( return nil } -// Upload uploads a file to S3 from a reader -func (Client) Upload( +// S3Upload uploads a file to S3 from a reader +func (Client) S3Upload( accessKey, secretKey, region, endpoint, bucketName, key string, fileReader io.Reader, ) error { @@ -86,8 +80,8 @@ func (Client) Upload( return nil } -// Delete deletes a file from S3 -func (Client) Delete( +// S3Delete deletes a file from S3 +func (Client) S3Delete( accessKey, secretKey, region, endpoint, bucketName, key string, ) error { s3Client, err := createS3Client( @@ -110,8 +104,8 @@ func (Client) Delete( return nil } -// GetDownloadLink generates a presigned URL for downloading a file from S3 -func (Client) GetDownloadLink( +// S3GetDownloadLink generates a presigned URL for downloading a file from S3 +func (Client) S3GetDownloadLink( accessKey, secretKey, region, endpoint, bucketName, key string, expiration time.Duration, ) (string, error) { @@ -135,3 +129,27 @@ func (Client) GetDownloadLink( return url, nil } + +// S3ReadFile reads a file from S3 +func (Client) S3ReadFile( + accessKey, secretKey, region, endpoint, bucketName, key string, +) (io.ReadCloser, error) { + s3Client, err := createS3Client( + accessKey, secretKey, region, endpoint, + ) + if err != nil { + return nil, err + } + + key = strutil.RemoveLeadingSlash(key) + + resp, err := s3Client.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + if err != nil { + return nil, fmt.Errorf("failed to read file from S3: %w", err) + } + + return resp.Body, nil +} diff --git a/internal/integration/storage/storage.go b/internal/integration/storage/storage.go new file mode 100644 index 0000000..202f21c --- /dev/null +++ b/internal/integration/storage/storage.go @@ -0,0 +1,7 @@ +package storage + +type Client struct{} + +func New() *Client { + return &Client{} +} diff --git a/internal/service/destinations/test_destination.go b/internal/service/destinations/test_destination.go index 84fd774..6fd9d06 100644 --- a/internal/service/destinations/test_destination.go +++ b/internal/service/destinations/test_destination.go @@ -5,7 +5,7 @@ import "fmt" func (s *Service) TestDestination( accessKey, secretKey, region, endpoint, bucketName string, ) error { - err := s.ints.S3Client.Ping(accessKey, secretKey, region, endpoint, bucketName) + err := s.ints.StorageClient.S3Ping(accessKey, secretKey, region, endpoint, bucketName) if err != nil { return fmt.Errorf("error pinging destination: %w", err) } diff --git a/internal/service/executions/get_execution_download_link.go b/internal/service/executions/get_execution_download_link.go index 87333d2..eeef303 100644 --- a/internal/service/executions/get_execution_download_link.go +++ b/internal/service/executions/get_execution_download_link.go @@ -26,7 +26,7 @@ func (s *Service) GetExecutionDownloadLink( return "", fmt.Errorf("execution has no file associated") } - return s.ints.S3Client.GetDownloadLink( + return s.ints.StorageClient.S3GetDownloadLink( data.DecryptedAccessKey, data.DecryptedSecretKey, data.Region, data.Endpoint, data.BucketName, data.Path.String, time.Hour*12, ) diff --git a/internal/service/executions/run_execution.go b/internal/service/executions/run_execution.go index 467716f..f4592bc 100644 --- a/internal/service/executions/run_execution.go +++ b/internal/service/executions/run_execution.go @@ -50,7 +50,7 @@ func (s *Service) RunExecution(ctx context.Context, backupID uuid.UUID) error { return err } - err = s.ints.S3Client.Ping( + err = s.ints.StorageClient.S3Ping( back.DecryptedDestinationAccessKey, back.DecryptedDestinationSecretKey, back.DestinationRegion, back.DestinationEndpoint, back.DestinationBucketName, ) @@ -105,7 +105,7 @@ func (s *Service) RunExecution(ctx context.Context, backupID uuid.UUID) error { ) path := strutil.CreatePath(false, back.BackupDestDir, date, file) - err = s.ints.S3Client.Upload( + err = s.ints.StorageClient.S3Upload( back.DecryptedDestinationAccessKey, back.DecryptedDestinationSecretKey, back.DestinationRegion, back.DestinationEndpoint, back.DestinationBucketName, path, dumpReader, diff --git a/internal/service/executions/soft_delete_execution.go b/internal/service/executions/soft_delete_execution.go index c6f3769..f7f0be2 100644 --- a/internal/service/executions/soft_delete_execution.go +++ b/internal/service/executions/soft_delete_execution.go @@ -26,7 +26,7 @@ func (s *Service) SoftDeleteExecution( } if execution.ExecutionPath.Valid { - err := s.ints.S3Client.Delete( + err := s.ints.StorageClient.S3Delete( execution.DecryptedDestinationAccessKey, execution.DecryptedDestinationSecretKey, execution.DestinationRegion, execution.DestinationEndpoint, execution.DestinationBucketName, execution.ExecutionPath.String, From 72ce05b102ab92e21415b360b7a5eaefa6eae47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 20:03:18 -0600 Subject: [PATCH 03/24] Update HTMX initialization and add MutationObserver for dynamic elements --- internal/view/static/js/app.js | 4 ++-- .../js/{init-htmx-triggers.js => init-htmx.js} | 17 ++++++++++++++++- package.json | 4 +++- 3 files changed, 21 insertions(+), 4 deletions(-) rename internal/view/static/js/{init-htmx-triggers.js => init-htmx.js} (70%) diff --git a/internal/view/static/js/app.js b/internal/view/static/js/app.js index 2a0da23..b865569 100644 --- a/internal/view/static/js/app.js +++ b/internal/view/static/js/app.js @@ -1,9 +1,9 @@ import { initNotyf } from './init-notyf.js' -import { initHTMXTriggers } from './init-htmx-triggers.js' +import { initHTMX } from './init-htmx.js' import { initAlpineComponents } from './init-alpine-components.js' import { initCopyFunction } from './init-copy-function.js' initNotyf() -initHTMXTriggers() +initHTMX() initAlpineComponents() initCopyFunction() diff --git a/internal/view/static/js/init-htmx-triggers.js b/internal/view/static/js/init-htmx.js similarity index 70% rename from internal/view/static/js/init-htmx-triggers.js rename to internal/view/static/js/init-htmx.js index add70dc..04a748b 100644 --- a/internal/view/static/js/init-htmx-triggers.js +++ b/internal/view/static/js/init-htmx.js @@ -1,4 +1,4 @@ -export function initHTMXTriggers () { +export function initHTMX () { const triggers = { ctm_alert: function (evt) { const message = decodeURIComponent(evt.detail.value) @@ -42,4 +42,19 @@ export function initHTMXTriggers () { for (const key in triggers) { document.addEventListener(key, triggers[key]) } + + /* + This fixes this issue: + https://stackoverflow.com/questions/73658449/htmx-request-not-firing-when-hx-attributes-are-added-dynamically-from-javascrip + */ + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === 1 && !node['htmx-internal-data']) { + htmx.process(node) + } + }) + }) + }) + observer.observe(document, { childList: true, subtree: true }) } diff --git a/package.json b/package.json index ad814e8..d02f8d7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "location", "toaster", "Alpine", - "Notyf" + "Notyf", + "htmx", + "MutationObserver" ] } } \ No newline at end of file From 421aa9a3ab12e4d692de4c5b8cb9bc57bda6e451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 20:04:22 -0600 Subject: [PATCH 04/24] Teleport all modals to body to allow nesting --- internal/view/web/component/modal.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/view/web/component/modal.go b/internal/view/web/component/modal.go index 92ca6e0..f40ec65 100644 --- a/internal/view/web/component/modal.go +++ b/internal/view/web/component/modal.go @@ -149,6 +149,12 @@ func Modal(params ModalParams) ModalResult { ), ) + content = alpine.Template( + alpine.XData(""), + alpine.XTeleport("body"), + html.Div(content), + ) + return ModalResult{ OpenerAttr: openerAttr, HTML: content, From 721fcf46e7af04b9cd5553d3e1afd712bac44490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 20:05:02 -0600 Subject: [PATCH 05/24] Add HelpButtonModal component for displaying a modal with a help button --- .../view/web/component/help_button_modal.go | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 internal/view/web/component/help_button_modal.go diff --git a/internal/view/web/component/help_button_modal.go b/internal/view/web/component/help_button_modal.go new file mode 100644 index 0000000..95542bd --- /dev/null +++ b/internal/view/web/component/help_button_modal.go @@ -0,0 +1,46 @@ +package component + +import ( + lucide "github.com/eduardolat/gomponents-lucide" + "github.com/maragudk/gomponents" + "github.com/maragudk/gomponents/components" + "github.com/maragudk/gomponents/html" +) + +type HelpButtonModalParams struct { + ModalTitle string + ModalSize size + ButtonSize size + Children []gomponents.Node +} + +func HelpButtonModal(params HelpButtonModalParams) gomponents.Node { + mo := Modal(ModalParams{ + Size: params.ModalSize, + Title: params.ModalTitle, + Content: params.Children, + }) + + button := html.Button( + mo.OpenerAttr, + components.Classes{ + "btn btn-neutral btn-ghost btn-circle": true, + "btn-sm": params.ButtonSize == SizeSm, + "btn-lg": params.ButtonSize == SizeLg, + }, + html.Type("button"), + lucide.CircleHelp( + components.Classes{ + "size-4": params.ButtonSize == SizeSm, + "size-6": params.ButtonSize == SizeMd, + "size-8": params.ButtonSize == SizeLg, + }, + ), + ) + + return html.Div( + html.Class("inline-block"), + mo.HTML, + button, + ) +} From 8ef584a8636fb5dd73797ed9670e470c6448d3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 20:10:30 -0600 Subject: [PATCH 06/24] Add ID generation and help button for InputControl and SelectControl components --- internal/view/web/component/input_control.go | 47 ++++++++++++++----- internal/view/web/component/select_control.go | 45 +++++++++++++----- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/internal/view/web/component/input_control.go b/internal/view/web/component/input_control.go index 74a8f5d..117cb7e 100644 --- a/internal/view/web/component/input_control.go +++ b/internal/view/web/component/input_control.go @@ -2,36 +2,45 @@ package component import ( lucide "github.com/eduardolat/gomponents-lucide" + "github.com/google/uuid" "github.com/maragudk/gomponents" "github.com/maragudk/gomponents/components" "github.com/maragudk/gomponents/html" ) type InputControlParams struct { - Name string - Label string - Placeholder string - Required bool - Type inputType - HelpText string - Color color - AutoComplete string - Children []gomponents.Node + ID string + Name string + Label string + Placeholder string + Required bool + Type inputType + HelpText string + Color color + AutoComplete string + Children []gomponents.Node + HelpButtonChildren []gomponents.Node } func InputControl(params InputControlParams) gomponents.Node { + id := params.ID + if id == "" { + id = "input-control-" + uuid.NewString() + } + if params.Type.Value == "" { params.Type = InputTypeText } - return html.Label( + return html.Div( components.Classes{ "form-control w-full": true, getTextColorClass(params.Color): true, }, html.Div( - html.Class("label"), - html.Div( + html.Class("label flex justify-start"), + html.Label( + html.For(id), html.Class("flex justify-start items-center space-x-1"), SpanText(params.Label), gomponents.If( @@ -39,12 +48,23 @@ func InputControl(params InputControlParams) gomponents.Node { lucide.Asterisk(html.Class("text-error")), ), ), + gomponents.If( + len(params.HelpButtonChildren) > 0, + HelpButtonModal(HelpButtonModalParams{ + ModalTitle: params.Label, + ButtonSize: SizeSm, + Children: []gomponents.Node{ + SpanText(params.HelpText), + }, + }), + ), ), html.Input( components.Classes{ "input input-bordered w-full": true, getInputColorClass(params.Color): true, }, + html.ID(id), html.Type(params.Type.Value), html.Name(params.Name), html.Placeholder(params.Placeholder), @@ -60,8 +80,9 @@ func InputControl(params InputControlParams) gomponents.Node { ), gomponents.If( params.HelpText != "", - html.Div( + html.Label( html.Class("label"), + html.For(id), SpanText(params.HelpText), ), ), diff --git a/internal/view/web/component/select_control.go b/internal/view/web/component/select_control.go index b197a36..aefcd61 100644 --- a/internal/view/web/component/select_control.go +++ b/internal/view/web/component/select_control.go @@ -2,31 +2,40 @@ package component import ( lucide "github.com/eduardolat/gomponents-lucide" + "github.com/google/uuid" "github.com/maragudk/gomponents" "github.com/maragudk/gomponents/components" "github.com/maragudk/gomponents/html" ) type SelectControlParams struct { - Name string - Label string - Placeholder string - Required bool - HelpText string - Color color - AutoComplete string - Children []gomponents.Node + ID string + Name string + Label string + Placeholder string + Required bool + HelpText string + Color color + AutoComplete string + Children []gomponents.Node + HelpButtonChildren []gomponents.Node } func SelectControl(params SelectControlParams) gomponents.Node { - return html.Label( + id := params.ID + if id == "" { + id = "select-control-" + uuid.NewString() + } + + return html.Div( components.Classes{ "form-control w-full": true, getTextColorClass(params.Color): true, }, html.Div( - html.Class("label"), - html.Div( + html.Class("label flex justify-start"), + html.Label( + html.For(id), html.Class("flex justify-start items-center space-x-1"), SpanText(params.Label), gomponents.If( @@ -34,12 +43,23 @@ func SelectControl(params SelectControlParams) gomponents.Node { lucide.Asterisk(html.Class("text-error")), ), ), + gomponents.If( + len(params.HelpButtonChildren) > 0, + HelpButtonModal(HelpButtonModalParams{ + ModalTitle: params.Label, + ButtonSize: SizeSm, + Children: []gomponents.Node{ + SpanText(params.HelpText), + }, + }), + ), ), html.Select( components.Classes{ "select select-bordered w-full": true, getSelectColorClass(params.Color): true, }, + html.ID(id), html.Name(params.Name), gomponents.If( params.Required, @@ -62,8 +82,9 @@ func SelectControl(params SelectControlParams) gomponents.Node { ), gomponents.If( params.HelpText != "", - html.Div( + html.Label( html.Class("label"), + html.For(id), SpanText(params.HelpText), ), ), From f0340e282aa1b9bdcf9b988f57b3b29f68cf28f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 20:20:36 -0600 Subject: [PATCH 07/24] Refactor help button implementation in InputControl and SelectControl components --- internal/view/web/component/input_control.go | 4 +--- internal/view/web/component/select_control.go | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/view/web/component/input_control.go b/internal/view/web/component/input_control.go index 117cb7e..021fe53 100644 --- a/internal/view/web/component/input_control.go +++ b/internal/view/web/component/input_control.go @@ -53,9 +53,7 @@ func InputControl(params InputControlParams) gomponents.Node { HelpButtonModal(HelpButtonModalParams{ ModalTitle: params.Label, ButtonSize: SizeSm, - Children: []gomponents.Node{ - SpanText(params.HelpText), - }, + Children: params.HelpButtonChildren, }), ), ), diff --git a/internal/view/web/component/select_control.go b/internal/view/web/component/select_control.go index aefcd61..a731f7b 100644 --- a/internal/view/web/component/select_control.go +++ b/internal/view/web/component/select_control.go @@ -48,9 +48,7 @@ func SelectControl(params SelectControlParams) gomponents.Node { HelpButtonModal(HelpButtonModalParams{ ModalTitle: params.Label, ButtonSize: SizeSm, - Children: []gomponents.Node{ - SpanText(params.HelpText), - }, + Children: params.HelpButtonChildren, }), ), ), From d66e79bca50bffe5347ebedba498143919425070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 21:05:01 -0600 Subject: [PATCH 08/24] Add convenience function for creating a B element with a text node --- internal/view/web/component/typography.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/view/web/component/typography.go b/internal/view/web/component/typography.go index e8a8e0b..c0d7530 100644 --- a/internal/view/web/component/typography.go +++ b/internal/view/web/component/typography.go @@ -119,3 +119,9 @@ func PText(text string) gomponents.Node { func SpanText(text string) gomponents.Node { return html.Span(gomponents.Text(text)) } + +// BText is a convenience function to create a B element with a +// simple text node as its child. +func BText(text string) gomponents.Node { + return html.B(gomponents.Text(text)) +} From d1f282d4133e1850cc5a0db14f8fa910162bd190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 21:06:40 -0600 Subject: [PATCH 09/24] Refactor help button implementation in InputControl and SelectControl components --- internal/view/web/component/help_button_modal.go | 16 ++-------------- internal/view/web/component/input_control.go | 1 - internal/view/web/component/select_control.go | 1 - 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/internal/view/web/component/help_button_modal.go b/internal/view/web/component/help_button_modal.go index 95542bd..48462f8 100644 --- a/internal/view/web/component/help_button_modal.go +++ b/internal/view/web/component/help_button_modal.go @@ -3,14 +3,12 @@ package component import ( lucide "github.com/eduardolat/gomponents-lucide" "github.com/maragudk/gomponents" - "github.com/maragudk/gomponents/components" "github.com/maragudk/gomponents/html" ) type HelpButtonModalParams struct { ModalTitle string ModalSize size - ButtonSize size Children []gomponents.Node } @@ -23,19 +21,9 @@ func HelpButtonModal(params HelpButtonModalParams) gomponents.Node { button := html.Button( mo.OpenerAttr, - components.Classes{ - "btn btn-neutral btn-ghost btn-circle": true, - "btn-sm": params.ButtonSize == SizeSm, - "btn-lg": params.ButtonSize == SizeLg, - }, + html.Class("btn btn-neutral btn-ghost btn-circle btn-sm"), html.Type("button"), - lucide.CircleHelp( - components.Classes{ - "size-4": params.ButtonSize == SizeSm, - "size-6": params.ButtonSize == SizeMd, - "size-8": params.ButtonSize == SizeLg, - }, - ), + lucide.CircleHelp(), ) return html.Div( diff --git a/internal/view/web/component/input_control.go b/internal/view/web/component/input_control.go index 021fe53..fde333e 100644 --- a/internal/view/web/component/input_control.go +++ b/internal/view/web/component/input_control.go @@ -52,7 +52,6 @@ func InputControl(params InputControlParams) gomponents.Node { len(params.HelpButtonChildren) > 0, HelpButtonModal(HelpButtonModalParams{ ModalTitle: params.Label, - ButtonSize: SizeSm, Children: params.HelpButtonChildren, }), ), diff --git a/internal/view/web/component/select_control.go b/internal/view/web/component/select_control.go index a731f7b..e85deee 100644 --- a/internal/view/web/component/select_control.go +++ b/internal/view/web/component/select_control.go @@ -47,7 +47,6 @@ func SelectControl(params SelectControlParams) gomponents.Node { len(params.HelpButtonChildren) > 0, HelpButtonModal(HelpButtonModalParams{ ModalTitle: params.Label, - ButtonSize: SizeSm, Children: params.HelpButtonChildren, }), ), From d157f8a72f5a7ace0ba0d8052a8bd1e2847c7eb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 21:26:56 -0600 Subject: [PATCH 10/24] Refactor input control to include pattern attribute --- internal/view/web/component/input_control.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/view/web/component/input_control.go b/internal/view/web/component/input_control.go index fde333e..1ec7799 100644 --- a/internal/view/web/component/input_control.go +++ b/internal/view/web/component/input_control.go @@ -18,6 +18,7 @@ type InputControlParams struct { HelpText string Color color AutoComplete string + Pattern string Children []gomponents.Node HelpButtonChildren []gomponents.Node } @@ -73,6 +74,10 @@ func InputControl(params InputControlParams) gomponents.Node { params.AutoComplete != "", html.AutoComplete(params.AutoComplete), ), + gomponents.If( + params.Pattern != "", + html.Pattern(params.Pattern), + ), gomponents.Group(params.Children), ), gomponents.If( From c577bf7d2d1a853d7814dd5f75a32d9dab4a7d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 21:27:51 -0600 Subject: [PATCH 11/24] Add migration to add is_local column to backups table --- ...40803171113_add_is_local_to_backups_table.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 internal/database/migrations/20240803171113_add_is_local_to_backups_table.sql diff --git a/internal/database/migrations/20240803171113_add_is_local_to_backups_table.sql b/internal/database/migrations/20240803171113_add_is_local_to_backups_table.sql new file mode 100644 index 0000000..32d4413 --- /dev/null +++ b/internal/database/migrations/20240803171113_add_is_local_to_backups_table.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE backups ALTER COLUMN destination_id DROP NOT NULL; +ALTER TABLE backups ADD COLUMN is_local BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE backups ADD CONSTRAINT backups_destination_check CHECK ( + (is_local = TRUE AND destination_id IS NULL) OR + (is_local = FALSE AND destination_id IS NOT NULL) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE backups DROP CONSTRAINT backups_destination_check; +ALTER TABLE backups DROP COLUMN is_local; +ALTER TABLE backups ALTER COLUMN destination_id SET NOT NULL; +-- +goose StatementEnd From ed929cc931252c3455e6b4405adc580453f212b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 21:28:14 -0600 Subject: [PATCH 12/24] Update backups service with is_local option --- internal/service/backups/create_backup.sql | 4 ++-- internal/service/backups/paginate_backups.sql | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/service/backups/create_backup.sql b/internal/service/backups/create_backup.sql index d6f43c7..2f419bc 100644 --- a/internal/service/backups/create_backup.sql +++ b/internal/service/backups/create_backup.sql @@ -1,11 +1,11 @@ -- name: BackupsServiceCreateBackup :one INSERT INTO backups ( - database_id, destination_id, name, cron_expression, time_zone, + database_id, destination_id, is_local, name, cron_expression, time_zone, is_active, dest_dir, retention_days, opt_data_only, opt_schema_only, opt_clean, opt_if_exists, opt_create, opt_no_comments ) VALUES ( - @database_id, @destination_id, @name, @cron_expression, @time_zone, + @database_id, @destination_id, @is_local, @name, @cron_expression, @time_zone, @is_active, @dest_dir, @retention_days, @opt_data_only, @opt_schema_only, @opt_clean, @opt_if_exists, @opt_create, @opt_no_comments ) diff --git a/internal/service/backups/paginate_backups.sql b/internal/service/backups/paginate_backups.sql index 6e897a3..bff1d69 100644 --- a/internal/service/backups/paginate_backups.sql +++ b/internal/service/backups/paginate_backups.sql @@ -7,7 +7,7 @@ SELECT databases.name AS database_name, destinations.name AS destination_name FROM backups -JOIN databases ON backups.database_id = databases.id -JOIN destinations ON backups.destination_id = destinations.id +INNER JOIN databases ON backups.database_id = databases.id +LEFT JOIN destinations ON backups.destination_id = destinations.id ORDER BY backups.created_at DESC LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset'); From fe7344e7b2907ac5745c11368fdf22f7961308fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 21:28:35 -0600 Subject: [PATCH 13/24] Update backups forms with is_local option and better docs --- internal/view/web/dashboard/backups/common.go | 147 ++++++++++++++++++ .../web/dashboard/backups/create_backup.go | 130 ++++++++-------- .../view/web/dashboard/backups/edit_backup.go | 93 +++++------ .../web/dashboard/backups/list_backups.go | 10 +- 4 files changed, 256 insertions(+), 124 deletions(-) create mode 100644 internal/view/web/dashboard/backups/common.go diff --git a/internal/view/web/dashboard/backups/common.go b/internal/view/web/dashboard/backups/common.go new file mode 100644 index 0000000..5ccc14a --- /dev/null +++ b/internal/view/web/dashboard/backups/common.go @@ -0,0 +1,147 @@ +package backups + +import ( + lucide "github.com/eduardolat/gomponents-lucide" + "github.com/eduardolat/pgbackweb/internal/view/web/component" + "github.com/maragudk/gomponents" + "github.com/maragudk/gomponents/components" + "github.com/maragudk/gomponents/html" +) + +func localBackupsHelp() []gomponents.Node { + return []gomponents.Node{ + component.H3Text("Local backups"), + component.PText(` + Local backups are stored in the server where PG Back Web is running. + They are stored under /backups directory so you can mount a docker + volume to this directory to persist the backups in any way you want. + `), + + html.Div( + html.Class("mt-2"), + component.H3Text("Remote backups"), + component.PText(` + Remote backups are stored in a destination. A destination is a remote + S3 compatible storage. With this option you don't need to worry about + creating and managing docker volumes. + `), + ), + } +} + +func cronExpressionHelp() []gomponents.Node { + return []gomponents.Node{ + component.PText(` + A cron expression is a string used to define a schedule for running tasks + in Unix-like operating systems. It consists of five fields representing + the minute, hour, day of the month, month, and day of the week. + Cron expressions enable precise scheduling of periodic tasks. + `), + + html.Div( + html.Class("mt-4 flex justify-end items-center"), + html.A( + html.Href("https://en.wikipedia.org/wiki/Cron"), + html.Target("_blank"), + html.Class("btn btn-ghost"), + component.SpanText("Learn more"), + lucide.ExternalLink(), + ), + html.A( + html.Href("https://crontab.guru/examples.html"), + html.Target("_blank"), + html.Class("btn btn-ghost"), + component.SpanText("Examples & common expressions"), + lucide.ExternalLink(), + ), + ), + } +} + +func destinationDirectoryHelp() []gomponents.Node { + return []gomponents.Node{ + component.PText(` + The destination directory is the directory where the backups will be + stored. This directory is relative to the base directory of the + destination. It should start with a slash, should not contain any + spaces, and should not end with a slash. + `), + + html.Div( + html.Class("mt-2"), + component.H3Text("Local backups"), + component.PText(` + For local backups, the base directory is /backups. So, the backup files + will be stored in: + `), + html.Div( + components.Classes{ + "whitespace-nowrap p-1": true, + "overflow-x-scroll": true, + "font-mono": true, + }, + component.BText( + "/backups////
/dump-.zip", + ), + ), + ), + + html.Div( + html.Class("mt-2"), + component.H3Text("Remote backups"), + component.PText(` + For remote backups, the base directory is the root of the bucket. So, + the backup files will be stored in: + `), + html.Div( + components.Classes{ + "whitespace-nowrap p-1": true, + "overflow-x-scroll": true, + "font-mono": true, + }, + component.BText( + "s3://////
/dump-.zip", + ), + ), + ), + } +} + +func retentionDaysHelp() []gomponents.Node { + return []gomponents.Node{ + component.PText(` + Retention days specifies the number of days to keep backup files before + they are automatically deleted. This ensures that old backups are removed + to save storage space. The retention period is evaluated by execution. + `), + } +} + +func pgDumpOptionsHelp() []gomponents.Node { + return []gomponents.Node{ + html.Div( + html.Class("space-y-2"), + + component.PText(` + This software uses the battle tested pg_dump utility to create backups. It + makes consistent backups even if the database is being used concurrently. + `), + + component.PText(` + These are options that will be passed to the pg_dump utility. By default, + PG Back Web does not pass any options so the backups are full backups. + `), + + html.Div( + html.Class("flex justify-end"), + html.A( + html.Class("btn btn-ghost"), + html.Href("https://www.postgresql.org/docs/current/app-pgdump.html"), + html.Target("_blank"), + component.SpanText("Learn more in pg_dump documentation"), + lucide.ExternalLink(html.Class("ml-1")), + ), + ), + ), + } +} diff --git a/internal/view/web/dashboard/backups/create_backup.go b/internal/view/web/dashboard/backups/create_backup.go index 6c38fd6..69c89b4 100644 --- a/internal/view/web/dashboard/backups/create_backup.go +++ b/internal/view/web/dashboard/backups/create_backup.go @@ -8,6 +8,7 @@ import ( "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/alpine" "github.com/eduardolat/pgbackweb/internal/view/web/component" "github.com/eduardolat/pgbackweb/internal/view/web/htmx" "github.com/google/uuid" @@ -21,7 +22,8 @@ func (h *handlers) createBackupHandler(c echo.Context) error { var formData struct { DatabaseID uuid.UUID `form:"database_id" validate:"required,uuid"` - DestinationID uuid.UUID `form:"destination_id" validate:"required,uuid"` + DestinationID uuid.UUID `form:"destination_id" validate:"omitempty,uuid"` + IsLocal string `form:"is_local" validate:"required,oneof=true false"` Name string `form:"name" validate:"required"` CronExpression string `form:"cron_expression" validate:"required"` TimeZone string `form:"time_zone" validate:"required"` @@ -44,8 +46,11 @@ func (h *handlers) createBackupHandler(c echo.Context) error { _, err := h.servs.BackupsService.CreateBackup( ctx, dbgen.BackupsServiceCreateBackupParams{ - DatabaseID: formData.DatabaseID, - DestinationID: formData.DestinationID, + DatabaseID: formData.DatabaseID, + DestinationID: uuid.NullUUID{ + Valid: formData.IsLocal == "false", UUID: formData.DestinationID, + }, + IsLocal: formData.IsLocal == "true", Name: formData.Name, CronExpression: formData.CronExpression, TimeZone: formData.TimeZone, @@ -101,13 +106,16 @@ func createBackupForm( htmx.HxDisabledELT("find button"), html.Class("space-y-2 text-base"), + alpine.XData(`{ + is_local: "false", + }`), + 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.SelectControl(component.SelectControlParams{ @@ -126,53 +134,46 @@ func createBackupForm( }), component.SelectControl(component.SelectControlParams{ - Name: "destination_id", - Label: "Destination", - Required: true, - Placeholder: "Select a destination", + Name: "is_local", + Label: "Local backup", + Required: true, Children: []gomponents.Node{ - component.GMap( - destinations, - func(dest dbgen.DestinationsServiceGetAllDestinationsRow) gomponents.Node { - return html.Option(html.Value(dest.ID.String()), gomponents.Text(dest.Name)) - }, - ), + alpine.XModel("is_local"), + html.Option(html.Value("true"), gomponents.Text("Yes")), + html.Option(html.Value("false"), gomponents.Text("No"), html.Selected()), }, + HelpButtonChildren: localBackupsHelp(), }), - html.Div( - component.InputControl(component.InputControlParams{ - Name: "cron_expression", - Label: "Cron expression", - Placeholder: "* * * * *", + alpine.Template( + alpine.XIf("is_local == 'false'"), + component.SelectControl(component.SelectControlParams{ + Name: "destination_id", + Label: "Destination", Required: true, - Type: component.InputTypeText, - HelpText: "The cron expression to schedule the backup", + Placeholder: "Select a destination", Children: []gomponents.Node{ - html.Pattern(`^\S+\s+\S+\s+\S+\s+\S+\s+\S+$`), + component.GMap( + destinations, + func(dest dbgen.DestinationsServiceGetAllDestinationsRow) gomponents.Node { + return html.Option(html.Value(dest.ID.String()), gomponents.Text(dest.Name)) + }, + ), }, }), - html.P( - html.Class("pl-1"), - gomponents.Text("Learn more about "), - html.A( - html.Class("link"), - html.Href("https://en.wikipedia.org/wiki/Cron"), - html.Target("_blank"), - gomponents.Text("cron expressions"), - lucide.ExternalLink(html.Class("inline ml-1")), - ), - gomponents.Text(" and "), - html.A( - html.Class("link"), - html.Href("https://crontab.guru/examples.html"), - html.Target("_blank"), - gomponents.Text("see some examples"), - lucide.ExternalLink(html.Class("inline ml-1")), - ), - ), ), + component.InputControl(component.InputControlParams{ + Name: "cron_expression", + Label: "Cron expression", + Placeholder: "* * * * *", + Required: true, + Type: component.InputTypeText, + HelpText: "The cron expression to schedule the backup", + Pattern: `^\S+\s+\S+\s+\S+\s+\S+\s+\S+$`, + HelpButtonChildren: cronExpressionHelp(), + }), + component.SelectControl(component.SelectControlParams{ Name: "time_zone", Label: "Time zone", @@ -190,25 +191,27 @@ func createBackupForm( }), component.InputControl(component.InputControlParams{ - Name: "dest_dir", - Label: "Destination directory", - Placeholder: "/path/to/backup", - Required: true, - Type: component.InputTypeText, - HelpText: "The directory where the backups will be stored", + Name: "dest_dir", + Label: "Destination directory", + Placeholder: "/path/to/backup", + Required: true, + Type: component.InputTypeText, + HelpText: "Relative to the base directory of the destination", + Pattern: `^\/\S*[^\/]$`, + HelpButtonChildren: destinationDirectoryHelp(), }), 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 backups. It is evaluated by execution and all backups before this will be deleted. Use 0 to keep them indefinitely", + Name: "retention_days", + Label: "Retention days", + Placeholder: "30", + Required: true, + Type: component.InputTypeNumber, + Pattern: "[0-9]+", + HelpButtonChildren: retentionDaysHelp(), Children: []gomponents.Node{ html.Min("0"), html.Max("36500"), - html.Pattern("[0-9]+"), }, }), @@ -224,18 +227,15 @@ func createBackupForm( html.Div( html.Class("pt-4"), - component.H2Text("Options"), - component.PText("These options are passed to the pg_dump command."), - html.P( - gomponents.Text("Learn more in the "), - html.A( - html.Class("link"), - html.Href("https://www.postgresql.org/docs/current/app-pgdump.html"), - html.Target("_blank"), - component.SpanText("pg_dump documentation"), - lucide.ExternalLink(html.Class("inline ml-1")), - ), + html.Div( + html.Class("flex justify-start items-center space-x-1"), + component.H2Text("Options"), + component.HelpButtonModal(component.HelpButtonModalParams{ + ModalTitle: "Backup options", + Children: pgDumpOptionsHelp(), + }), ), + html.Div( html.Class("mt-2 grid grid-cols-2 gap-2"), diff --git a/internal/view/web/dashboard/backups/edit_backup.go b/internal/view/web/dashboard/backups/edit_backup.go index bef8793..ae2538f 100644 --- a/internal/view/web/dashboard/backups/edit_backup.go +++ b/internal/view/web/dashboard/backups/edit_backup.go @@ -100,45 +100,24 @@ func editBackupButton(backup dbgen.BackupsServicePaginateBackupsRow) gomponents. Placeholder: "My backup", Required: true, Type: component.InputTypeText, - HelpText: "A name to easily identify the backup", Children: []gomponents.Node{ html.Value(backup.Name), }, }), - html.Div( - 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.Pattern(`^\S+\s+\S+\s+\S+\s+\S+\s+\S+$`), - html.Value(backup.CronExpression), - }, - }), - html.P( - html.Class("pl-1"), - gomponents.Text("Learn more about "), - html.A( - html.Class("link"), - html.Href("https://en.wikipedia.org/wiki/Cron"), - html.Target("_blank"), - gomponents.Text("cron expressions"), - lucide.ExternalLink(html.Class("inline ml-1")), - ), - gomponents.Text(" and "), - html.A( - html.Class("link"), - html.Href("https://crontab.guru/examples.html"), - html.Target("_blank"), - gomponents.Text("see some examples"), - lucide.ExternalLink(html.Class("inline ml-1")), - ), - ), - ), + component.InputControl(component.InputControlParams{ + Name: "cron_expression", + Label: "Cron expression", + Placeholder: "* * * * *", + Required: true, + Type: component.InputTypeText, + HelpText: "The cron expression to schedule the backup", + Pattern: `^\S+\s+\S+\s+\S+\s+\S+\s+\S+$`, + Children: []gomponents.Node{ + html.Value(backup.CronExpression), + }, + HelpButtonChildren: cronExpressionHelp(), + }), component.SelectControl(component.SelectControlParams{ Name: "time_zone", @@ -164,28 +143,30 @@ func editBackupButton(backup dbgen.BackupsServicePaginateBackupsRow) gomponents. }), component.InputControl(component.InputControlParams{ - Name: "dest_dir", - Label: "Destination directory", - Placeholder: "/path/to/backup", - Required: true, - Type: component.InputTypeText, - HelpText: "The directory where the backups will be stored", + Name: "dest_dir", + Label: "Destination directory", + Placeholder: "/path/to/backup", + Required: true, + Type: component.InputTypeText, + HelpText: "Relative to the base directory of the destination", + HelpButtonChildren: destinationDirectoryHelp(), + Pattern: `^\/\S*[^\/]$`, 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 backups. It is evaluated by execution and all backups before this will be deleted. Use 0 to keep them indefinitely", + Name: "retention_days", + Label: "Retention days", + Placeholder: "30", + Required: true, + Type: component.InputTypeNumber, + Pattern: "[0-9]+", + HelpButtonChildren: retentionDaysHelp(), Children: []gomponents.Node{ html.Min("0"), html.Max("36500"), - html.Pattern("[0-9]+"), html.Value(fmt.Sprintf("%d", backup.RetentionDays)), }, }), @@ -201,17 +182,13 @@ func editBackupButton(backup dbgen.BackupsServicePaginateBackupsRow) gomponents. html.Div( html.Class("pt-4"), - component.H2Text("Options"), - component.PText("These options are passed to the pg_dump command."), - html.P( - gomponents.Text("Learn more in the "), - html.A( - html.Class("link"), - html.Href("https://www.postgresql.org/docs/current/app-pgdump.html"), - html.Target("_blank"), - component.SpanText("pg_dump documentation"), - lucide.ExternalLink(html.Class("inline ml-1")), - ), + html.Div( + html.Class("flex justify-start items-center space-x-1"), + component.H2Text("Options"), + component.HelpButtonModal(component.HelpButtonModalParams{ + ModalTitle: "Backup options", + Children: pgDumpOptionsHelp(), + }), ), html.Div( diff --git a/internal/view/web/dashboard/backups/list_backups.go b/internal/view/web/dashboard/backups/list_backups.go index 466c9e0..77a280d 100644 --- a/internal/view/web/dashboard/backups/list_backups.go +++ b/internal/view/web/dashboard/backups/list_backups.go @@ -94,7 +94,15 @@ func listBackups( ), ), html.Td(component.SpanText(backup.DatabaseName)), - html.Td(component.SpanText(backup.DestinationName)), + html.Td(component.SpanText(func() string { + if backup.DestinationName.Valid { + return backup.DestinationName.String + } + if backup.IsLocal { + return "Local" + } + return "Unknown" + }())), html.Td( html.Class("font-mono"), html.Div( From 1b188bc547c1071ffbc320f3d7ee6be3beb302f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 21:33:58 -0600 Subject: [PATCH 14/24] Add description for S3 destinations on dashboard page --- internal/view/web/dashboard/destinations/index.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/view/web/dashboard/destinations/index.go b/internal/view/web/dashboard/destinations/index.go index b494fc1..c6ca68d 100644 --- a/internal/view/web/dashboard/destinations/index.go +++ b/internal/view/web/dashboard/destinations/index.go @@ -23,6 +23,12 @@ func indexPage() gomponents.Node { component.H1Text("S3 Destinations"), createDestinationButton(), ), + + component.PText(` + Here you can manage your S3 destinations. You can skip creating a S3 + destination if you want to use the local storage for your backups. + `), + component.CardBox(component.CardBoxParams{ Class: "mt-4", Children: []gomponents.Node{ From 9aaf66912e96d596c26801d2133e690a3a7c285d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 21:40:37 -0600 Subject: [PATCH 15/24] Update tailwind.config.js with additional color options --- tailwind.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tailwind.config.js b/tailwind.config.js index 55e8f25..66c6804 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,7 +8,9 @@ module.exports = { { light: { ...require('daisyui/src/theming/themes').light, - primary: '#2be7c8' + primary: '#2be7c8', + 'success-content': '#ffffff', + 'error-content': '#ffffff' }, dark: { ...require('daisyui/src/theming/themes').dracula, From 8c08ddb4db2daef4201c1c5db745c2bdb2840937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 21:46:14 -0600 Subject: [PATCH 16/24] Update SQL query for pagination and display backup's local status in executions list --- .../service/executions/paginate_executions.sql | 15 ++++++++------- .../web/dashboard/executions/list_executions.go | 10 +++++++++- .../web/dashboard/executions/show_execution.go | 10 +++++++++- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/internal/service/executions/paginate_executions.sql b/internal/service/executions/paginate_executions.sql index 024ec21..5c4c98c 100644 --- a/internal/service/executions/paginate_executions.sql +++ b/internal/service/executions/paginate_executions.sql @@ -1,9 +1,9 @@ -- name: ExecutionsServicePaginateExecutionsCount :one SELECT COUNT(executions.*) FROM executions -JOIN backups ON backups.id = executions.backup_id -JOIN databases ON databases.id = backups.database_id -JOIN destinations ON destinations.id = backups.destination_id +INNER JOIN backups ON backups.id = executions.backup_id +INNER JOIN databases ON databases.id = backups.database_id +LEFT JOIN destinations ON destinations.id = backups.destination_id WHERE ( sqlc.narg('backup_id')::UUID IS NULL @@ -28,11 +28,12 @@ SELECT executions.*, backups.name AS backup_name, databases.name AS database_name, - destinations.name AS destination_name + destinations.name AS destination_name, + backups.is_local AS backup_is_local FROM executions -JOIN backups ON backups.id = executions.backup_id -JOIN databases ON databases.id = backups.database_id -JOIN destinations ON destinations.id = backups.destination_id +INNER JOIN backups ON backups.id = executions.backup_id +INNER JOIN databases ON databases.id = backups.database_id +LEFT JOIN destinations ON destinations.id = backups.destination_id WHERE ( sqlc.narg('backup_id')::UUID IS NULL diff --git a/internal/view/web/dashboard/executions/list_executions.go b/internal/view/web/dashboard/executions/list_executions.go index 1c96829..ec0d70c 100644 --- a/internal/view/web/dashboard/executions/list_executions.go +++ b/internal/view/web/dashboard/executions/list_executions.go @@ -79,7 +79,15 @@ func listExecutions( html.Td(component.StatusBadge(execution.Status)), html.Td(component.SpanText(execution.BackupName)), html.Td(component.SpanText(execution.DatabaseName)), - html.Td(component.SpanText(execution.DestinationName)), + html.Td(component.SpanText(func() string { + if execution.DestinationName.Valid { + return execution.DestinationName.String + } + if execution.BackupIsLocal { + return "Local" + } + return "Unknown" + }())), html.Td(component.SpanText( execution.StartedAt.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty), )), diff --git a/internal/view/web/dashboard/executions/show_execution.go b/internal/view/web/dashboard/executions/show_execution.go index 011b9a5..85cf261 100644 --- a/internal/view/web/dashboard/executions/show_execution.go +++ b/internal/view/web/dashboard/executions/show_execution.go @@ -55,7 +55,15 @@ func showExecutionButton( ), html.Tr( html.Th(component.SpanText("Destination")), - html.Td(component.SpanText(execution.DestinationName)), + html.Td(component.SpanText(func() string { + if execution.DestinationName.Valid { + return execution.DestinationName.String + } + if execution.BackupIsLocal { + return "Local" + } + return "Unknown" + }())), ), gomponents.If( execution.Message.Valid, From a6bb8ac83922d5ec08233b034cd3be002dc1f1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 21:49:54 -0600 Subject: [PATCH 17/24] Better parameter name in local storage methods --- internal/integration/storage/local.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/integration/storage/local.go b/internal/integration/storage/local.go index 3c28920..5024f2c 100644 --- a/internal/integration/storage/local.go +++ b/internal/integration/storage/local.go @@ -13,8 +13,8 @@ const ( // LocalUpload Creates a new file using the provided path and reader relative // to the local backups directory. -func (Client) LocalUpload(relativePath string, fileReader io.Reader) error { - fullPath := filepath.Join(localBackupsDir, relativePath) +func (Client) LocalUpload(relativeFilePath string, fileReader io.Reader) error { + fullPath := filepath.Join(localBackupsDir, relativeFilePath) dir := filepath.Dir(fullPath) err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm) @@ -38,8 +38,8 @@ func (Client) LocalUpload(relativePath string, fileReader io.Reader) error { // LocalDelete Deletes a file using the provided path relative to the local // backups directory. -func (Client) LocalDelete(relativePath string) error { - fullPath := filepath.Join(localBackupsDir, relativePath) +func (Client) LocalDelete(relativeFilePath string) error { + fullPath := filepath.Join(localBackupsDir, relativeFilePath) err := os.Remove(fullPath) if err != nil { @@ -51,8 +51,8 @@ func (Client) LocalDelete(relativePath string) error { // LocalReadFile Reads a file using the provided path relative to the local // backups directory. -func (Client) LocalReadFile(relativePath string) (io.ReadCloser, error) { - fullPath := filepath.Join(localBackupsDir, relativePath) +func (Client) LocalReadFile(relativeFilePath string) (io.ReadCloser, error) { + fullPath := filepath.Join(localBackupsDir, relativeFilePath) file, err := os.Open(fullPath) if err != nil { From e7d1e59186f5ae4e0cc6ddefd54e93dd2d7bbb4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 21:51:43 -0600 Subject: [PATCH 18/24] Refactor local storage methods to use utility function for creating file paths --- internal/integration/storage/local.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/integration/storage/local.go b/internal/integration/storage/local.go index 5024f2c..1c1dfc9 100644 --- a/internal/integration/storage/local.go +++ b/internal/integration/storage/local.go @@ -5,6 +5,8 @@ import ( "io" "os" "path/filepath" + + "github.com/eduardolat/pgbackweb/internal/util/strutil" ) const ( @@ -14,7 +16,7 @@ const ( // LocalUpload Creates a new file using the provided path and reader relative // to the local backups directory. func (Client) LocalUpload(relativeFilePath string, fileReader io.Reader) error { - fullPath := filepath.Join(localBackupsDir, relativeFilePath) + fullPath := strutil.CreatePath(true, localBackupsDir, relativeFilePath) dir := filepath.Dir(fullPath) err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm) @@ -39,7 +41,7 @@ func (Client) LocalUpload(relativeFilePath string, fileReader io.Reader) error { // LocalDelete Deletes a file using the provided path relative to the local // backups directory. func (Client) LocalDelete(relativeFilePath string) error { - fullPath := filepath.Join(localBackupsDir, relativeFilePath) + fullPath := strutil.CreatePath(true, localBackupsDir, relativeFilePath) err := os.Remove(fullPath) if err != nil { @@ -52,7 +54,7 @@ func (Client) LocalDelete(relativeFilePath string) error { // LocalReadFile Reads a file using the provided path relative to the local // backups directory. func (Client) LocalReadFile(relativeFilePath string) (io.ReadCloser, error) { - fullPath := filepath.Join(localBackupsDir, relativeFilePath) + fullPath := strutil.CreatePath(true, localBackupsDir, relativeFilePath) file, err := os.Open(fullPath) if err != nil { From f485d53ce8470f62b8095c144515405020cf8c2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 22:11:50 -0600 Subject: [PATCH 19/24] Add local backups to execution process --- internal/service/executions/run_execution.go | 71 ++++++++++++------- internal/service/executions/run_execution.sql | 19 +++-- 2 files changed, 60 insertions(+), 30 deletions(-) diff --git a/internal/service/executions/run_execution.go b/internal/service/executions/run_execution.go index f4592bc..94bcd4a 100644 --- a/internal/service/executions/run_execution.go +++ b/internal/service/executions/run_execution.go @@ -50,18 +50,21 @@ func (s *Service) RunExecution(ctx context.Context, backupID uuid.UUID) error { return err } - err = s.ints.StorageClient.S3Ping( - back.DecryptedDestinationAccessKey, back.DecryptedDestinationSecretKey, - back.DestinationRegion, back.DestinationEndpoint, back.DestinationBucketName, - ) - if err != nil { - logError(err) - return updateExec(dbgen.ExecutionsServiceUpdateExecutionParams{ - ID: ex.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 !back.BackupIsLocal { + err = s.ints.StorageClient.S3Ping( + back.DecryptedDestinationAccessKey, back.DecryptedDestinationSecretKey, + back.DestinationRegion.String, back.DestinationEndpoint.String, + back.DestinationBucketName.String, + ) + if err != nil { + logError(err) + return updateExec(dbgen.ExecutionsServiceUpdateExecutionParams{ + ID: ex.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } } pgVersion, err := s.ints.PGClient.ParseVersion(back.DatabasePgVersion) @@ -105,20 +108,36 @@ func (s *Service) RunExecution(ctx context.Context, backupID uuid.UUID) error { ) path := strutil.CreatePath(false, back.BackupDestDir, date, file) - err = s.ints.StorageClient.S3Upload( - back.DecryptedDestinationAccessKey, back.DecryptedDestinationSecretKey, - back.DestinationRegion, back.DestinationEndpoint, back.DestinationBucketName, - path, dumpReader, - ) - if err != nil { - logError(err) - return updateExec(dbgen.ExecutionsServiceUpdateExecutionParams{ - ID: ex.ID, - Status: sql.NullString{Valid: true, String: "failed"}, - Message: sql.NullString{Valid: true, String: err.Error()}, - Path: sql.NullString{Valid: true, String: path}, - FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, - }) + if back.BackupIsLocal { + err = s.ints.StorageClient.LocalUpload(path, dumpReader) + if err != nil { + logError(err) + return updateExec(dbgen.ExecutionsServiceUpdateExecutionParams{ + ID: ex.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + Path: sql.NullString{Valid: true, String: path}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } + } + + if !back.BackupIsLocal { + err = s.ints.StorageClient.S3Upload( + back.DecryptedDestinationAccessKey, back.DecryptedDestinationSecretKey, + back.DestinationRegion.String, back.DestinationEndpoint.String, + back.DestinationBucketName.String, path, dumpReader, + ) + if err != nil { + logError(err) + return updateExec(dbgen.ExecutionsServiceUpdateExecutionParams{ + ID: ex.ID, + Status: sql.NullString{Valid: true, String: "failed"}, + Message: sql.NullString{Valid: true, String: err.Error()}, + Path: sql.NullString{Valid: true, String: path}, + FinishedAt: sql.NullTime{Valid: true, Time: time.Now()}, + }) + } } logger.Info("backup created successfully", logger.KV{ diff --git a/internal/service/executions/run_execution.sql b/internal/service/executions/run_execution.sql index ea9cb38..8b7552b 100644 --- a/internal/service/executions/run_execution.sql +++ b/internal/service/executions/run_execution.sql @@ -1,6 +1,7 @@ -- name: ExecutionsServiceGetBackupData :one SELECT backups.is_active as backup_is_active, + backups.is_local as backup_is_local, backups.dest_dir as backup_dest_dir, backups.opt_data_only as backup_opt_data_only, backups.opt_schema_only as backup_opt_schema_only, @@ -15,9 +16,19 @@ SELECT destinations.bucket_name as destination_bucket_name, destinations.region as destination_region, destinations.endpoint as destination_endpoint, - pgp_sym_decrypt(destinations.access_key, @encryption_key) AS decrypted_destination_access_key, - pgp_sym_decrypt(destinations.secret_key, @encryption_key) AS decrypted_destination_secret_key + ( + CASE WHEN destinations.access_key IS NOT NULL + THEN pgp_sym_decrypt(destinations.access_key, @encryption_key) + ELSE '' + END + ) AS decrypted_destination_access_key, + ( + CASE WHEN destinations.secret_key IS NOT NULL + THEN pgp_sym_decrypt(destinations.secret_key, @encryption_key) + ELSE '' + END + ) AS decrypted_destination_secret_key FROM backups -JOIN databases ON backups.database_id = databases.id -JOIN destinations ON backups.destination_id = destinations.id +INNER JOIN databases ON backups.database_id = databases.id +LEFT JOIN destinations ON backups.destination_id = destinations.id WHERE backups.id = @backup_id; From cceb30b2fbf03588b790feb6b49aeeed8b3f5918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 22:22:45 -0600 Subject: [PATCH 20/24] Refactor storage methods to remove unused code --- internal/integration/storage/local.go | 15 ++++----------- internal/integration/storage/s3.go | 24 ------------------------ 2 files changed, 4 insertions(+), 35 deletions(-) diff --git a/internal/integration/storage/local.go b/internal/integration/storage/local.go index 1c1dfc9..6ecf5d3 100644 --- a/internal/integration/storage/local.go +++ b/internal/integration/storage/local.go @@ -51,15 +51,8 @@ func (Client) LocalDelete(relativeFilePath string) error { return nil } -// LocalReadFile Reads a file using the provided path relative to the local -// backups directory. -func (Client) LocalReadFile(relativeFilePath string) (io.ReadCloser, error) { - fullPath := strutil.CreatePath(true, localBackupsDir, relativeFilePath) - - file, err := os.Open(fullPath) - if err != nil { - return nil, fmt.Errorf("failed to open file %s: %w", fullPath, err) - } - - return file, nil +// LocalGetFullPath Returns the full path of a file using the provided relative +// file path to the local backups directory. +func (Client) LocalGetFullPath(relativeFilePath string) string { + return strutil.CreatePath(true, localBackupsDir, relativeFilePath) } diff --git a/internal/integration/storage/s3.go b/internal/integration/storage/s3.go index 841ea0a..c9929e2 100644 --- a/internal/integration/storage/s3.go +++ b/internal/integration/storage/s3.go @@ -129,27 +129,3 @@ func (Client) S3GetDownloadLink( return url, nil } - -// S3ReadFile reads a file from S3 -func (Client) S3ReadFile( - accessKey, secretKey, region, endpoint, bucketName, key string, -) (io.ReadCloser, error) { - s3Client, err := createS3Client( - accessKey, secretKey, region, endpoint, - ) - if err != nil { - return nil, err - } - - key = strutil.RemoveLeadingSlash(key) - - resp, err := s3Client.GetObject(&s3.GetObjectInput{ - Bucket: aws.String(bucketName), - Key: aws.String(key), - }) - if err != nil { - return nil, fmt.Errorf("failed to read file from S3: %w", err) - } - - return resp.Body, nil -} From 8853b6f13eed3fe4d5848a20cfe802f41f3d0ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 22:31:12 -0600 Subject: [PATCH 21/24] Fix download button to support local backups --- .../executions/get_execution_download_link.go | 33 ------------- .../get_execution_download_link.sql | 12 ----- .../get_execution_download_link_or_path.go | 47 +++++++++++++++++++ .../get_execution_download_link_or_path.sql | 24 ++++++++++ .../dashboard/executions/show_execution.go | 22 +++++---- 5 files changed, 85 insertions(+), 53 deletions(-) delete mode 100644 internal/service/executions/get_execution_download_link.go delete mode 100644 internal/service/executions/get_execution_download_link.sql create mode 100644 internal/service/executions/get_execution_download_link_or_path.go create mode 100644 internal/service/executions/get_execution_download_link_or_path.sql diff --git a/internal/service/executions/get_execution_download_link.go b/internal/service/executions/get_execution_download_link.go deleted file mode 100644 index eeef303..0000000 --- a/internal/service/executions/get_execution_download_link.go +++ /dev/null @@ -1,33 +0,0 @@ -package executions - -import ( - "context" - "fmt" - "time" - - "github.com/eduardolat/pgbackweb/internal/database/dbgen" - "github.com/google/uuid" -) - -func (s *Service) GetExecutionDownloadLink( - ctx context.Context, executionID uuid.UUID, -) (string, error) { - data, err := s.dbgen.ExecutionsServiceGetDownloadLinkData( - ctx, dbgen.ExecutionsServiceGetDownloadLinkDataParams{ - ExecutionID: executionID, - DecryptionKey: *s.env.PBW_ENCRYPTION_KEY, - }, - ) - if err != nil { - return "", err - } - - if !data.Path.Valid { - return "", fmt.Errorf("execution has no file associated") - } - - return s.ints.StorageClient.S3GetDownloadLink( - data.DecryptedAccessKey, data.DecryptedSecretKey, data.Region, - data.Endpoint, data.BucketName, data.Path.String, time.Hour*12, - ) -} diff --git a/internal/service/executions/get_execution_download_link.sql b/internal/service/executions/get_execution_download_link.sql deleted file mode 100644 index 360dfc1..0000000 --- a/internal/service/executions/get_execution_download_link.sql +++ /dev/null @@ -1,12 +0,0 @@ --- name: ExecutionsServiceGetDownloadLinkData :one -SELECT - executions.path AS path, - destinations.bucket_name AS bucket_name, - destinations.region AS region, - destinations.endpoint AS endpoint, - pgp_sym_decrypt(destinations.access_key, sqlc.arg('decryption_key')::TEXT) AS decrypted_access_key, - pgp_sym_decrypt(destinations.secret_key, sqlc.arg('decryption_key')::TEXT) AS decrypted_secret_key -FROM executions -JOIN backups ON backups.id = executions.backup_id -JOIN destinations ON destinations.id = backups.destination_id -WHERE executions.id = @execution_id; diff --git a/internal/service/executions/get_execution_download_link_or_path.go b/internal/service/executions/get_execution_download_link_or_path.go new file mode 100644 index 0000000..70e4553 --- /dev/null +++ b/internal/service/executions/get_execution_download_link_or_path.go @@ -0,0 +1,47 @@ +package executions + +import ( + "context" + "fmt" + "time" + + "github.com/eduardolat/pgbackweb/internal/database/dbgen" + "github.com/google/uuid" +) + +// GetExecutionDownloadLinkOrPath returns a download link for the file associated +// with the given execution. If the execution is stored locally, the link will +// be a file path. +// +// Returns a boolean indicating if the file is locally stored and the download +// link/path. +func (s *Service) GetExecutionDownloadLinkOrPath( + ctx context.Context, executionID uuid.UUID, +) (bool, string, error) { + data, err := s.dbgen.ExecutionsServiceGetDownloadLinkOrPathData( + ctx, dbgen.ExecutionsServiceGetDownloadLinkOrPathDataParams{ + ExecutionID: executionID, + DecryptionKey: *s.env.PBW_ENCRYPTION_KEY, + }, + ) + if err != nil { + return false, "", err + } + + if !data.Path.Valid { + return false, "", fmt.Errorf("execution has no file associated") + } + + if data.IsLocal { + return true, s.ints.StorageClient.LocalGetFullPath(data.Path.String), nil + } + + link, err := s.ints.StorageClient.S3GetDownloadLink( + data.DecryptedAccessKey, data.DecryptedSecretKey, data.Region.String, + data.Endpoint.String, data.BucketName.String, data.Path.String, time.Hour*12, + ) + if err != nil { + return false, "", err + } + return false, link, nil +} diff --git a/internal/service/executions/get_execution_download_link_or_path.sql b/internal/service/executions/get_execution_download_link_or_path.sql new file mode 100644 index 0000000..1aa37d0 --- /dev/null +++ b/internal/service/executions/get_execution_download_link_or_path.sql @@ -0,0 +1,24 @@ +-- name: ExecutionsServiceGetDownloadLinkOrPathData :one +SELECT + executions.path AS path, + backups.is_local AS is_local, + destinations.bucket_name AS bucket_name, + destinations.region AS region, + destinations.endpoint AS endpoint, + destinations.endpoint as destination_endpoint, + ( + CASE WHEN destinations.access_key IS NOT NULL + THEN pgp_sym_decrypt(destinations.access_key, sqlc.arg('decryption_key')::TEXT) + ELSE '' + END + ) AS decrypted_access_key, + ( + CASE WHEN destinations.secret_key IS NOT NULL + THEN pgp_sym_decrypt(destinations.secret_key, sqlc.arg('decryption_key')::TEXT) + ELSE '' + END + ) AS decrypted_secret_key +FROM executions +INNER JOIN backups ON backups.id = executions.backup_id +LEFT JOIN destinations ON destinations.id = backups.destination_id +WHERE executions.id = @execution_id; diff --git a/internal/view/web/dashboard/executions/show_execution.go b/internal/view/web/dashboard/executions/show_execution.go index 85cf261..77e9806 100644 --- a/internal/view/web/dashboard/executions/show_execution.go +++ b/internal/view/web/dashboard/executions/show_execution.go @@ -1,11 +1,13 @@ package executions import ( + "net/http" + "path/filepath" + 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/eduardolat/pgbackweb/internal/view/web/htmx" "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/maragudk/gomponents" @@ -17,17 +19,21 @@ func (h *handlers) downloadExecutionHandler(c echo.Context) error { executionID, err := uuid.Parse(c.Param("executionID")) if err != nil { - return htmx.RespondToastError(c, err.Error()) + return c.String(http.StatusBadRequest, err.Error()) } - link, err := h.servs.ExecutionsService.GetExecutionDownloadLink( + isLocal, link, err := h.servs.ExecutionsService.GetExecutionDownloadLinkOrPath( ctx, executionID, ) if err != nil { - return htmx.RespondToastError(c, err.Error()) + return c.String(http.StatusInternalServerError, err.Error()) } - return htmx.RespondRedirect(c, link) + if isLocal { + return c.Attachment(link, filepath.Base(link)) + } + + return c.Redirect(http.StatusFound, link) } func showExecutionButton( @@ -114,9 +120,9 @@ func showExecutionButton( html.Div( html.Class("flex justify-end items-center space-x-2"), deleteExecutionButton(execution.ID), - html.Button( - htmx.HxGet("/dashboard/executions/"+execution.ID.String()+"/download"), - htmx.HxDisabledELT("this"), + html.A( + html.Href("/dashboard/executions/"+execution.ID.String()+"/download"), + html.Target("_blank"), html.Class("btn btn-primary"), component.SpanText("Download"), lucide.Download(), From 2a600f705b6b1a7162b88cd746ef450b89a9a575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 22:39:58 -0600 Subject: [PATCH 22/24] Add local support for soft deleting execution backups --- .../executions/soft_delete_execution.go | 13 ++++++++++--- .../executions/soft_delete_execution.sql | 19 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/internal/service/executions/soft_delete_execution.go b/internal/service/executions/soft_delete_execution.go index f7f0be2..742d85c 100644 --- a/internal/service/executions/soft_delete_execution.go +++ b/internal/service/executions/soft_delete_execution.go @@ -25,16 +25,23 @@ func (s *Service) SoftDeleteExecution( return err } - if execution.ExecutionPath.Valid { + if execution.ExecutionPath.Valid && !execution.BackupIsLocal { err := s.ints.StorageClient.S3Delete( execution.DecryptedDestinationAccessKey, execution.DecryptedDestinationSecretKey, - execution.DestinationRegion, execution.DestinationEndpoint, - execution.DestinationBucketName, execution.ExecutionPath.String, + execution.DestinationRegion.String, execution.DestinationEndpoint.String, + execution.DestinationBucketName.String, execution.ExecutionPath.String, ) if err != nil { return err } } + if execution.ExecutionPath.Valid && execution.BackupIsLocal { + err := s.ints.StorageClient.LocalDelete(execution.ExecutionPath.String) + if err != nil { + return err + } + } + return s.dbgen.ExecutionsServiceSoftDeleteExecution(ctx, executionID) } diff --git a/internal/service/executions/soft_delete_execution.sql b/internal/service/executions/soft_delete_execution.sql index 94d5a1d..888aa15 100644 --- a/internal/service/executions/soft_delete_execution.sql +++ b/internal/service/executions/soft_delete_execution.sql @@ -4,15 +4,26 @@ SELECT executions.path as execution_path, backups.id as backup_id, + backups.is_local as backup_is_local, destinations.bucket_name as destination_bucket_name, destinations.region as destination_region, destinations.endpoint as destination_endpoint, - pgp_sym_decrypt(destinations.access_key, @encryption_key) AS decrypted_destination_access_key, - pgp_sym_decrypt(destinations.secret_key, @encryption_key) AS decrypted_destination_secret_key + ( + CASE WHEN destinations.access_key IS NOT NULL + THEN pgp_sym_decrypt(destinations.access_key, sqlc.arg('encryption_key')::TEXT) + ELSE '' + END + ) AS decrypted_destination_access_key, + ( + CASE WHEN destinations.secret_key IS NOT NULL + THEN pgp_sym_decrypt(destinations.secret_key, sqlc.arg('encryption_key')::TEXT) + ELSE '' + END + ) AS decrypted_destination_secret_key FROM executions -JOIN backups ON backups.id = executions.backup_id -JOIN destinations ON destinations.id = backups.destination_id +INNER JOIN backups ON backups.id = executions.backup_id +LEFT JOIN destinations ON destinations.id = backups.destination_id WHERE executions.id = @execution_id; -- name: ExecutionsServiceSoftDeleteExecution :exec From 7b1c8754d0bbac0960a2ef545b6016a84b977b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 22:44:21 -0600 Subject: [PATCH 23/24] Fix styling --- internal/view/web/dashboard/backups/common.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/view/web/dashboard/backups/common.go b/internal/view/web/dashboard/backups/common.go index 5ccc14a..916e94c 100644 --- a/internal/view/web/dashboard/backups/common.go +++ b/internal/view/web/dashboard/backups/common.go @@ -39,7 +39,7 @@ func cronExpressionHelp() []gomponents.Node { `), html.Div( - html.Class("mt-4 flex justify-end items-center"), + html.Class("mt-4 flex justify-end items-center space-x-1"), html.A( html.Href("https://en.wikipedia.org/wiki/Cron"), html.Target("_blank"), From 44146cdd6e81361338c2053b62534e5326a6e1bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Eduardo=20Jer=C3=A9z=20Gir=C3=B3n?= Date: Sat, 3 Aug 2024 22:54:10 -0600 Subject: [PATCH 24/24] Add pretty destination name --- .../web/component/pretty_destination_name.go | 35 +++++++++++++++++++ .../web/dashboard/backups/list_backups.go | 12 ++----- .../dashboard/executions/list_executions.go | 12 ++----- .../dashboard/executions/show_execution.go | 12 ++----- 4 files changed, 44 insertions(+), 27 deletions(-) create mode 100644 internal/view/web/component/pretty_destination_name.go diff --git a/internal/view/web/component/pretty_destination_name.go b/internal/view/web/component/pretty_destination_name.go new file mode 100644 index 0000000..dec1b94 --- /dev/null +++ b/internal/view/web/component/pretty_destination_name.go @@ -0,0 +1,35 @@ +package component + +import ( + "database/sql" + + lucide "github.com/eduardolat/gomponents-lucide" + "github.com/maragudk/gomponents" + "github.com/maragudk/gomponents/html" +) + +func PrettyDestinationName( + isLocal bool, destinationName sql.NullString, +) gomponents.Node { + icon := lucide.Cloud + if !destinationName.Valid { + destinationName = sql.NullString{ + Valid: true, + String: "Unknown destination", + } + } + + if isLocal { + icon = lucide.HardDrive + destinationName = sql.NullString{ + Valid: true, + String: "Local", + } + } + + return html.Span( + html.Class("inline flex justify-start items-center space-x-1 font-mono"), + icon(), + SpanText(destinationName.String), + ) +} diff --git a/internal/view/web/dashboard/backups/list_backups.go b/internal/view/web/dashboard/backups/list_backups.go index 77a280d..740934f 100644 --- a/internal/view/web/dashboard/backups/list_backups.go +++ b/internal/view/web/dashboard/backups/list_backups.go @@ -94,15 +94,9 @@ func listBackups( ), ), html.Td(component.SpanText(backup.DatabaseName)), - html.Td(component.SpanText(func() string { - if backup.DestinationName.Valid { - return backup.DestinationName.String - } - if backup.IsLocal { - return "Local" - } - return "Unknown" - }())), + html.Td(component.PrettyDestinationName( + backup.IsLocal, backup.DestinationName, + )), html.Td( html.Class("font-mono"), html.Div( diff --git a/internal/view/web/dashboard/executions/list_executions.go b/internal/view/web/dashboard/executions/list_executions.go index ec0d70c..dc5f1b8 100644 --- a/internal/view/web/dashboard/executions/list_executions.go +++ b/internal/view/web/dashboard/executions/list_executions.go @@ -79,15 +79,9 @@ func listExecutions( html.Td(component.StatusBadge(execution.Status)), html.Td(component.SpanText(execution.BackupName)), html.Td(component.SpanText(execution.DatabaseName)), - html.Td(component.SpanText(func() string { - if execution.DestinationName.Valid { - return execution.DestinationName.String - } - if execution.BackupIsLocal { - return "Local" - } - return "Unknown" - }())), + html.Td(component.PrettyDestinationName( + execution.BackupIsLocal, execution.DestinationName, + )), html.Td(component.SpanText( execution.StartedAt.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty), )), diff --git a/internal/view/web/dashboard/executions/show_execution.go b/internal/view/web/dashboard/executions/show_execution.go index 77e9806..2b5e369 100644 --- a/internal/view/web/dashboard/executions/show_execution.go +++ b/internal/view/web/dashboard/executions/show_execution.go @@ -61,15 +61,9 @@ func showExecutionButton( ), html.Tr( html.Th(component.SpanText("Destination")), - html.Td(component.SpanText(func() string { - if execution.DestinationName.Valid { - return execution.DestinationName.String - } - if execution.BackupIsLocal { - return "Local" - } - return "Unknown" - }())), + html.Td(component.PrettyDestinationName( + execution.BackupIsLocal, execution.DestinationName, + )), ), gomponents.If( execution.Message.Valid,