mirror of
https://github.com/eduardolat/pgbackweb.git
synced 2026-01-25 22:18:51 -06:00
Merge pull request #17 from eduardolat/feat/local-backups
Feat/local backups
This commit is contained in:
@@ -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 #
|
||||
|
||||
@@ -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 #
|
||||
|
||||
@@ -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 #
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
58
internal/integration/storage/local.go
Normal file
58
internal/integration/storage/local.go
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
7
internal/integration/storage/storage.go
Normal file
7
internal/integration/storage/storage.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package storage
|
||||
|
||||
type Client struct{}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
34
internal/view/web/component/help_button_modal.go
Normal file
34
internal/view/web/component/help_button_modal.go
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
35
internal/view/web/component/pretty_destination_name.go
Normal file
35
internal/view/web/component/pretty_destination_name.go
Normal 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),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
147
internal/view/web/dashboard/backups/common.go
Normal file
147
internal/view/web/dashboard/backups/common.go
Normal 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")),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
)),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"location",
|
||||
"toaster",
|
||||
"Alpine",
|
||||
"Notyf"
|
||||
"Notyf",
|
||||
"htmx",
|
||||
"MutationObserver"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user