Merge pull request #17 from eduardolat/feat/local-backups

Feat/local backups
This commit is contained in:
Luis Eduardo
2024-08-03 23:08:19 -06:00
committed by GitHub
37 changed files with 725 additions and 286 deletions

View File

@@ -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 #

View File

@@ -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 #

View File

@@ -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 #

View File

@@ -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

View File

@@ -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,
}
}

View File

@@ -0,0 +1,58 @@
package storage
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/eduardolat/pgbackweb/internal/util/strutil"
)
const (
localBackupsDir string = "/backups"
)
// 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 := strutil.CreatePath(true, localBackupsDir, relativeFilePath)
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(relativeFilePath string) error {
fullPath := strutil.CreatePath(true, localBackupsDir, relativeFilePath)
err := os.Remove(fullPath)
if err != nil {
return fmt.Errorf("failed to delete file %s: %w", fullPath, err)
}
return 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)
}

View File

@@ -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) {

View File

@@ -0,0 +1,7 @@
package storage
type Client struct{}
func New() *Client {
return &Client{}
}

View File

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

View File

@@ -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');

View File

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

View File

@@ -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.S3Client.GetDownloadLink(
data.DecryptedAccessKey, data.DecryptedSecretKey, data.Region,
data.Endpoint, data.BucketName, data.Path.String, time.Hour*12,
)
}

View File

@@ -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;

View File

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

View File

@@ -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;

View File

@@ -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

View File

@@ -50,18 +50,21 @@ func (s *Service) RunExecution(ctx context.Context, backupID uuid.UUID) error {
return err
}
err = s.ints.S3Client.Ping(
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.S3Client.Upload(
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{

View File

@@ -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;

View File

@@ -25,16 +25,23 @@ func (s *Service) SoftDeleteExecution(
return err
}
if execution.ExecutionPath.Valid {
err := s.ints.S3Client.Delete(
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)
}

View File

@@ -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

View File

@@ -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()

View File

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

View File

@@ -0,0 +1,34 @@
package component
import (
lucide "github.com/eduardolat/gomponents-lucide"
"github.com/maragudk/gomponents"
"github.com/maragudk/gomponents/html"
)
type HelpButtonModalParams struct {
ModalTitle string
ModalSize 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,
html.Class("btn btn-neutral btn-ghost btn-circle btn-sm"),
html.Type("button"),
lucide.CircleHelp(),
)
return html.Div(
html.Class("inline-block"),
mo.HTML,
button,
)
}

View File

@@ -2,36 +2,46 @@ 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
Pattern 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 +49,20 @@ func InputControl(params InputControlParams) gomponents.Node {
lucide.Asterisk(html.Class("text-error")),
),
),
gomponents.If(
len(params.HelpButtonChildren) > 0,
HelpButtonModal(HelpButtonModalParams{
ModalTitle: params.Label,
Children: params.HelpButtonChildren,
}),
),
),
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),
@@ -56,12 +74,17 @@ 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(
params.HelpText != "",
html.Div(
html.Label(
html.Class("label"),
html.For(id),
SpanText(params.HelpText),
),
),

View File

@@ -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,

View File

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

View File

@@ -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,20 @@ func SelectControl(params SelectControlParams) gomponents.Node {
lucide.Asterisk(html.Class("text-error")),
),
),
gomponents.If(
len(params.HelpButtonChildren) > 0,
HelpButtonModal(HelpButtonModalParams{
ModalTitle: params.Label,
Children: params.HelpButtonChildren,
}),
),
),
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 +79,9 @@ func SelectControl(params SelectControlParams) gomponents.Node {
),
gomponents.If(
params.HelpText != "",
html.Div(
html.Label(
html.Class("label"),
html.For(id),
SpanText(params.HelpText),
),
),

View File

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

View File

@@ -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 space-x-1"),
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/<destination-directory>/<YYYY>/<MM>/<DD>/dump-<random-suffix>.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://<bucket>/<destination-directory>/<YYYY>/<MM>/<DD>/dump-<random-suffix>.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")),
),
),
),
}
}

View File

@@ -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"),

View File

@@ -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(

View File

@@ -94,7 +94,9 @@ func listBackups(
),
),
html.Td(component.SpanText(backup.DatabaseName)),
html.Td(component.SpanText(backup.DestinationName)),
html.Td(component.PrettyDestinationName(
backup.IsLocal, backup.DestinationName,
)),
html.Td(
html.Class("font-mono"),
html.Div(

View File

@@ -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{

View File

@@ -79,7 +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(execution.DestinationName)),
html.Td(component.PrettyDestinationName(
execution.BackupIsLocal, execution.DestinationName,
)),
html.Td(component.SpanText(
execution.StartedAt.Format(timeutil.LayoutYYYYMMDDHHMMSSPretty),
)),

View File

@@ -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(
@@ -55,7 +61,9 @@ func showExecutionButton(
),
html.Tr(
html.Th(component.SpanText("Destination")),
html.Td(component.SpanText(execution.DestinationName)),
html.Td(component.PrettyDestinationName(
execution.BackupIsLocal, execution.DestinationName,
)),
),
gomponents.If(
execution.Message.Valid,
@@ -106,9 +114,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(),

View File

@@ -18,7 +18,9 @@
"location",
"toaster",
"Alpine",
"Notyf"
"Notyf",
"htmx",
"MutationObserver"
]
}
}

View File

@@ -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,