working
This commit is contained in:
commit
d840597cb8
12
.env.example
Normal file
12
.env.example
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
SMTP_HOST=mail.toto.fr
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=backea@maric.ro
|
||||||
|
SMTP_PASSWORD=CHANGEME
|
||||||
|
SMTP_FROM=backea@maric.ro
|
||||||
|
|
||||||
|
B2_APPLICATION_KEY=B2
|
||||||
|
B2_KEY_ID=b2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
kopia.env
|
||||||
|
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
bin/
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
config/
|
47
Dockerfile
Normal file
47
Dockerfile
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
RUN go install github.com/a-h/templ/cmd/templ@latest
|
||||||
|
|
||||||
|
COPY go.mod go.sum* ./
|
||||||
|
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN /go/bin/templ generate
|
||||||
|
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/backea-server ./cmd/server/main.go
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/backup-performer ./cmd/backup_performer/main.go
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/list-backups ./cmd/list-backups/main.go
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN echo "@edge http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
rsync \
|
||||||
|
zip \
|
||||||
|
unzip \
|
||||||
|
borgbackup \
|
||||||
|
python3 \
|
||||||
|
py3-pip \
|
||||||
|
curl \
|
||||||
|
fuse \
|
||||||
|
openssh-client \
|
||||||
|
ca-certificates \
|
||||||
|
kopia@edge
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /app/backea-server .
|
||||||
|
COPY --from=builder /app/backup-performer .
|
||||||
|
COPY --from=builder /app/list-backups .
|
||||||
|
|
||||||
|
COPY --from=builder /app/static ./static
|
||||||
|
COPY --from=builder /app/templates ./templates
|
||||||
|
|
||||||
|
RUN chmod +x /app/backea-server /app/backup-performer /app/list-backups
|
||||||
|
|
||||||
|
CMD ["/app/backea-server"]
|
37
cmd/backup_performer/main.go
Normal file
37
cmd/backup_performer/main.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backea/internal/backup"
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "config.yml", "Path to config file")
|
||||||
|
serviceFlag := flag.String("service", "", "Service to backup (format: group or group.index)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
log.Printf("Warning: Error loading .env file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Parse the service flag to extract group and index if provided
|
||||||
|
var serviceName, serviceIndex string
|
||||||
|
if *serviceFlag != "" {
|
||||||
|
parts := strings.SplitN(*serviceFlag, "-", 2)
|
||||||
|
serviceName = parts[0]
|
||||||
|
if len(parts) > 1 {
|
||||||
|
serviceIndex = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := backup.PerformBackups(ctx, *configPath, serviceName, serviceIndex); err != nil {
|
||||||
|
log.Fatalf("Backup failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
98
cmd/list-backups/main.go
Normal file
98
cmd/list-backups/main.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backea/internal/backup"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
configPath := "config.yml"
|
||||||
|
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
log.Printf("Warning: Error loading .env file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup factory
|
||||||
|
factory, err := backup.NewBackupFactory(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create backup factory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each service group
|
||||||
|
for groupName, serviceGroup := range factory.Config.Services {
|
||||||
|
fmt.Printf("Service Group: %s (Directory: %s)\n", groupName, serviceGroup.Source.Path)
|
||||||
|
|
||||||
|
// Process each backup config in the group
|
||||||
|
for configIndex := range serviceGroup.BackupConfigs {
|
||||||
|
// Format the full service name
|
||||||
|
serviceName := fmt.Sprintf("%s.%s", groupName, configIndex)
|
||||||
|
|
||||||
|
// Create the appropriate backup strategy using the factory
|
||||||
|
strategy, err := factory.CreateBackupStrategyForService(groupName, configIndex)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create backup strategy for service %s: %v", serviceName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// List backups for this service
|
||||||
|
backups, err := strategy.ListBackups(ctx, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to list backups for service %s: %v", serviceName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display the backups
|
||||||
|
fmt.Printf("Found %d backups for service %s:\n", len(backups), serviceName)
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "----------------------------------------------------")
|
||||||
|
fmt.Fprintln(w, "ID | Date | Size | Retention")
|
||||||
|
fmt.Fprintln(w, "----------------------------------------------------")
|
||||||
|
for _, backup := range backups {
|
||||||
|
fmt.Fprintf(w, "%s | %s | %s | %s\n",
|
||||||
|
backup.ID,
|
||||||
|
backup.CreationTime.Format(time.RFC3339),
|
||||||
|
formatSize(backup.Size),
|
||||||
|
backup.RetentionTag,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "----------------------------------------------------")
|
||||||
|
w.Flush()
|
||||||
|
|
||||||
|
// Display storage usage
|
||||||
|
usage, err := strategy.GetStorageUsage(ctx, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to get storage usage for service %s: %v", serviceName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Storage Usage for %s Repository\n", serviceName)
|
||||||
|
fmt.Println("---------------------------------------")
|
||||||
|
fmt.Printf("Provider: %s\n", usage.Provider)
|
||||||
|
fmt.Printf("Repository ID: %s\n", usage.ProviderID)
|
||||||
|
fmt.Printf("Physical Size: %s\n", formatSize(usage.TotalBytes))
|
||||||
|
|
||||||
|
fmt.Println() // Add a blank line between services for readability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSize(size int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if size < unit {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := size / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f %cB", float64(size)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
43
cmd/list-buckets/main.go
Normal file
43
cmd/list-buckets/main.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
b2_client "backea/internal/client"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Create context with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create B2 client
|
||||||
|
client, err := b2_client.NewClientFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating B2 client: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List buckets
|
||||||
|
buckets, err := client.ListBuckets(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error listing buckets: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print buckets
|
||||||
|
fmt.Println("B2 Buckets:")
|
||||||
|
fmt.Println("===========")
|
||||||
|
if len(buckets) == 0 {
|
||||||
|
fmt.Println("No buckets found")
|
||||||
|
} else {
|
||||||
|
for i, bucket := range buckets {
|
||||||
|
fmt.Printf("%d. %s (ID: %s)\n",
|
||||||
|
i+1,
|
||||||
|
bucket.Name,
|
||||||
|
bucket.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
cmd/notify/main.go
Normal file
34
cmd/notify/main.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"backea/internal/mail"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
log.Println("Warning: No .env file found")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := mail.NewClient()
|
||||||
|
|
||||||
|
// Check if required environment variables are set
|
||||||
|
if client.Config.Host == "" || client.Config.Username == "" || client.Config.Password == "" {
|
||||||
|
log.Fatal("Missing required SMTP configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notification
|
||||||
|
recipients := []string{"romaric.sirii@gmail.com"}
|
||||||
|
subject := "Backup Notification"
|
||||||
|
body := "Your backup has completed successfully!"
|
||||||
|
|
||||||
|
if err := client.SendMail(recipients, subject, body); err != nil {
|
||||||
|
log.Fatalf("Failed to send email: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Notification sent successfully")
|
||||||
|
}
|
51
cmd/restore/main.go
Normal file
51
cmd/restore/main.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backea/internal/backup"
|
||||||
|
b2_client "backea/internal/client"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
log.Println("Starting Kopia Restore Tool")
|
||||||
|
|
||||||
|
// Create context with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create B2 client
|
||||||
|
b2Client, err := b2_client.NewClientFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to create B2 client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create restore manager
|
||||||
|
restoreManager := backup.NewRestoreManager(b2Client)
|
||||||
|
|
||||||
|
// List snapshots for the 'montre' service
|
||||||
|
serviceName := "montre"
|
||||||
|
log.Printf("Listing snapshots for service '%s' (bucket: 'backea-%s')", serviceName, serviceName)
|
||||||
|
|
||||||
|
err = restoreManager.ListSnapshots(ctx, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to list snapshots: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage of RestoreFile function
|
||||||
|
// Use the complete snapshot ID from the output
|
||||||
|
// Looking at your output, let's use the most recent snapshot for /home/gevo/Documents/backea2
|
||||||
|
snapshotID := "kdf031570cab920b40d296bda16d27ae7" // The latest snapshot ID
|
||||||
|
sourcePath := "" // Leave empty to restore the entire snapshot
|
||||||
|
targetPath := "./restored_files" // Local directory to restore files to
|
||||||
|
|
||||||
|
log.Printf("Restoring files from snapshot %s to %s", snapshotID, targetPath)
|
||||||
|
err = restoreManager.RestoreFile(ctx, serviceName, snapshotID, sourcePath, targetPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to restore files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Restore tool completed successfully")
|
||||||
|
}
|
26
cmd/server/main.go
Normal file
26
cmd/server/main.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backea/internal/backup"
|
||||||
|
"backea/internal/server"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Get port from environment or use default
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup factory
|
||||||
|
backupFactory, _ := backup.NewBackupFactory("config.yml")
|
||||||
|
|
||||||
|
// Initialize and start the server
|
||||||
|
srv := server.New(backupFactory)
|
||||||
|
log.Printf("Starting server on port %s", port)
|
||||||
|
if err := srv.Start(":" + port); err != nil {
|
||||||
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
34
compose.yaml
Normal file
34
compose.yaml
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
backea:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
# Configuration files
|
||||||
|
- ./.env:/app/.env
|
||||||
|
- ./config.yml:/app/config.yml
|
||||||
|
- ./kopia.env:/app/kopia.env
|
||||||
|
|
||||||
|
# Source directories to backup - map to identical paths inside container
|
||||||
|
- /home/gevo/Documents/backea2:/home/gevo/Documents/backea2
|
||||||
|
- /home/gevo/Images:/home/gevo/Images
|
||||||
|
|
||||||
|
# Dedicated repository storage
|
||||||
|
- /home/gevo/.kopia:/root/.kopia
|
||||||
|
|
||||||
|
# SSH keys if needed
|
||||||
|
- /home/gevo/.ssh:/root/.ssh:ro
|
||||||
|
- ./config/kopia:/tmp/kopia_configs
|
||||||
|
environment:
|
||||||
|
- HOME=/root
|
||||||
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
|
devices:
|
||||||
|
- /dev/fuse:/dev/fuse
|
||||||
|
security_opt:
|
||||||
|
- apparmor:unconfined
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
58
config.yml
Normal file
58
config.yml
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
defaults:
|
||||||
|
retention:
|
||||||
|
keep_latest: 10
|
||||||
|
keep_hourly: 24
|
||||||
|
keep_daily: 30
|
||||||
|
keep_weekly: 8
|
||||||
|
keep_monthly: 12
|
||||||
|
keep_yearly: 3
|
||||||
|
|
||||||
|
services:
|
||||||
|
backealocal:
|
||||||
|
source:
|
||||||
|
host: "local"
|
||||||
|
path: "/home/gevo/Documents/backea2"
|
||||||
|
hooks:
|
||||||
|
before_hook: "ls"
|
||||||
|
after_hook: ""
|
||||||
|
backup_configs:
|
||||||
|
1:
|
||||||
|
backup_strategy:
|
||||||
|
type: "kopia"
|
||||||
|
provider: "local"
|
||||||
|
destination:
|
||||||
|
path: "/home/gevo/Documents/backea2"
|
||||||
|
2:
|
||||||
|
backup_strategy:
|
||||||
|
type: "kopia"
|
||||||
|
provider: "local"
|
||||||
|
destination:
|
||||||
|
path: "/home/gevo/Documents/backea2"
|
||||||
|
|
||||||
|
imageslocal:
|
||||||
|
source:
|
||||||
|
host: "local"
|
||||||
|
path: "/home/gevo/Images/"
|
||||||
|
hooks:
|
||||||
|
before_hook: "ls"
|
||||||
|
after_hook: ""
|
||||||
|
backup_configs:
|
||||||
|
1:
|
||||||
|
backup_strategy:
|
||||||
|
type: "kopia"
|
||||||
|
provider: "local"
|
||||||
|
destination:
|
||||||
|
host: "local"
|
||||||
|
|
||||||
|
b2backupstotozzeeeaaaeeee:
|
||||||
|
source:
|
||||||
|
host: "local"
|
||||||
|
path: "/home/gevo/Images/"
|
||||||
|
hooks:
|
||||||
|
before_hook: "ls"
|
||||||
|
after_hook: ""
|
||||||
|
backup_configs:
|
||||||
|
1:
|
||||||
|
backup_strategy:
|
||||||
|
type: "kopia"
|
||||||
|
provider: "b2_backblaze"
|
46
go.mod
Normal file
46
go.mod
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
module backea
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/a-h/templ v0.3.833
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/labstack/echo/v4 v4.13.3
|
||||||
|
github.com/spf13/viper v1.19.0
|
||||||
|
gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/golang/glog v1.2.4 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/readahead v0.0.0-20161222183148-eaceba169032 // indirect
|
||||||
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7 // indirect
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
github.com/spf13/afero v1.11.0 // indirect
|
||||||
|
github.com/spf13/cast v1.6.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/crypto v0.32.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||||
|
golang.org/x/net v0.34.0 // indirect
|
||||||
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
golang.org/x/time v0.9.0 // indirect
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
103
go.sum
Normal file
103
go.sum
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
|
||||||
|
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc=
|
||||||
|
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/readahead v0.0.0-20161222183148-eaceba169032 h1:6Be3nkuJFyRfCgr6qTIzmRp8y9QwDIbqy/nYr9WDPos=
|
||||||
|
github.com/google/readahead v0.0.0-20161222183148-eaceba169032/go.mod h1:qYysrqQXuV4tzsizt4oOQ6mrBZQ0xnQXP3ylXX8Jk5Y=
|
||||||
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||||
|
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7 h1:xoIK0ctDddBMnc74udxJYBqlo9Ylnsp1waqjLsnef20=
|
||||||
|
github.com/pquerna/ffjson v0.0.0-20190930134022-aa0246cd15f7/go.mod h1:YARuvh7BUWHNhzDq2OM5tzR2RiCcN2D7sapiKyCel/M=
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
|
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||||
|
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||||
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
|
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||||
|
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||||
|
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||||
|
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||||
|
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||||
|
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||||
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||||
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||||
|
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||||
|
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
|
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||||
|
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216 h1:2TSTkQ8PMvGOD5eeqqRVv6Z9+BYI+bowK97RCr3W+9M=
|
||||||
|
gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216/go.mod h1:zJ2QpyDCYo1KvLXlmdnFlQAyF/Qfth0fB8239Qg7BIE=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
131
internal/backup/backup.go
Normal file
131
internal/backup/backup.go
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backea/internal/mail"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PerformBackups executes backups for multiple services based on configuration
|
||||||
|
func PerformBackups(ctx context.Context, configPath string, serviceName string, serviceIndex string) error {
|
||||||
|
// Create backup factory
|
||||||
|
factory, err := NewBackupFactory(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize mailer
|
||||||
|
mailer := mail.NewMailer()
|
||||||
|
|
||||||
|
// Process services
|
||||||
|
if serviceName != "" {
|
||||||
|
// Process single service group or specific service
|
||||||
|
if serviceIndex != "" {
|
||||||
|
// Process specific service within a group
|
||||||
|
return processSpecificService(ctx, factory, serviceName, serviceIndex, mailer)
|
||||||
|
} else {
|
||||||
|
// Process all services in the specified group
|
||||||
|
return processServiceGroup(ctx, factory, serviceName, mailer)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Process all service groups in parallel
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, len(factory.Config.Services))
|
||||||
|
for groupName := range factory.Config.Services {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(group string) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := processServiceGroup(ctx, factory, group, mailer); err != nil {
|
||||||
|
log.Printf("Failed to backup service group %s: %v", group, err)
|
||||||
|
errs <- fmt.Errorf("backup failed for group %s: %w", group, err)
|
||||||
|
}
|
||||||
|
}(groupName)
|
||||||
|
}
|
||||||
|
// Wait for all backups to complete
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
|
||||||
|
// Check if any errors occurred
|
||||||
|
var lastErr error
|
||||||
|
for err := range errs {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processServiceGroup handles the backup for all services in a group
|
||||||
|
func processServiceGroup(ctx context.Context, factory *BackupFactory, groupName string, mailer *mail.Mailer) error {
|
||||||
|
// Get service group configuration
|
||||||
|
serviceGroup, exists := factory.Config.Services[groupName]
|
||||||
|
if !exists {
|
||||||
|
log.Printf("Service group not found: %s", groupName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the before hook once for the entire group
|
||||||
|
if serviceGroup.Hooks.BeforeHook != "" {
|
||||||
|
log.Printf("Executing before hook for group %s: %s", groupName, serviceGroup.Hooks.BeforeHook)
|
||||||
|
if err := RunCommand(serviceGroup.Source.Path, serviceGroup.Hooks.BeforeHook); err != nil {
|
||||||
|
log.Printf("Failed to execute before hook for group %s: %v", groupName, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process all services in the group in parallel
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, len(serviceGroup.BackupConfigs))
|
||||||
|
for configIndex := range serviceGroup.BackupConfigs {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(group, index string) {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := processSpecificService(ctx, factory, group, index, mailer); err != nil {
|
||||||
|
log.Printf("Failed to backup service %s.%s: %v", group, index, err)
|
||||||
|
errs <- fmt.Errorf("backup failed for %s.%s: %w", group, index, err)
|
||||||
|
}
|
||||||
|
}(groupName, configIndex)
|
||||||
|
}
|
||||||
|
// Wait for all backups to complete
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
|
||||||
|
// Execute the after hook once for the entire group
|
||||||
|
if serviceGroup.Hooks.AfterHook != "" {
|
||||||
|
log.Printf("Executing after hook for group %s: %s", groupName, serviceGroup.Hooks.AfterHook)
|
||||||
|
if err := RunCommand(serviceGroup.Source.Path, serviceGroup.Hooks.AfterHook); err != nil {
|
||||||
|
log.Printf("Failed to execute after hook for group %s: %v", groupName, err)
|
||||||
|
// We don't return here because we want to process the errors from the backups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any errors occurred
|
||||||
|
var lastErr error
|
||||||
|
for err := range errs {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// processSpecificService handles the backup for a specific service in a group
|
||||||
|
func processSpecificService(ctx context.Context, factory *BackupFactory, groupName string, configIndex string, mailer *mail.Mailer) error {
|
||||||
|
// Get service configuration
|
||||||
|
serviceGroup := factory.Config.Services[groupName]
|
||||||
|
|
||||||
|
// Create the appropriate backup strategy using the factory
|
||||||
|
strategy, err := factory.CreateBackupStrategyForService(groupName, configIndex)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to create backup strategy for service %s.%s: %v", groupName, configIndex, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and run service
|
||||||
|
service := NewService(
|
||||||
|
fmt.Sprintf("%s.%s", groupName, configIndex),
|
||||||
|
serviceGroup.Source.Path,
|
||||||
|
strategy,
|
||||||
|
mailer,
|
||||||
|
)
|
||||||
|
return service.Backup(ctx)
|
||||||
|
}
|
187
internal/backup/backup_factory.go
Normal file
187
internal/backup/backup_factory.go
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackupFactory creates backup strategies and managers
|
||||||
|
type BackupFactory struct {
|
||||||
|
Config *Configuration
|
||||||
|
ConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBackupFactory creates a new backup factory
|
||||||
|
func NewBackupFactory(configPath string) (*BackupFactory, error) {
|
||||||
|
// Load configuration
|
||||||
|
config, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not load config: %w", err)
|
||||||
|
}
|
||||||
|
return &BackupFactory{
|
||||||
|
Config: config,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKopiaStrategy creates a new Kopia backup strategy with specific retention settings and provider
|
||||||
|
func (f *BackupFactory) CreateKopiaStrategy(retention Retention, provider KopiaProvider) *KopiaStrategy {
|
||||||
|
// Create temp directory for Kopia configs if it doesn't exist
|
||||||
|
tmpConfigDir := filepath.Join(os.TempDir(), "kopia_configs")
|
||||||
|
if err := os.MkdirAll(tmpConfigDir, 0755); err != nil {
|
||||||
|
log.Printf("Warning: failed to create temp config directory: %v", err)
|
||||||
|
// Fall back to default config path
|
||||||
|
tmpConfigDir = os.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique config file path for this instance
|
||||||
|
configPath := filepath.Join(tmpConfigDir, fmt.Sprintf("kopia_%s_%d.config",
|
||||||
|
provider.GetProviderType(), time.Now().UnixNano()))
|
||||||
|
|
||||||
|
// Need to update this line to pass configPath
|
||||||
|
return NewKopiaStrategy(retention, provider, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKopiaProvider creates the appropriate Kopia provider based on config
|
||||||
|
func (f *BackupFactory) CreateKopiaProvider(strategyConfig StrategyConfig) (KopiaProvider, error) {
|
||||||
|
switch strategyConfig.Provider {
|
||||||
|
case "b2_backblaze":
|
||||||
|
return NewKopiaB2Provider(), nil
|
||||||
|
case "local":
|
||||||
|
// Extract options for local path if specified
|
||||||
|
basePath := ""
|
||||||
|
if strategyConfig.Options != "" {
|
||||||
|
basePath = strategyConfig.Options
|
||||||
|
} else {
|
||||||
|
// Default to ~/.backea/repos if not specified
|
||||||
|
basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos")
|
||||||
|
}
|
||||||
|
return NewKopiaLocalProvider(basePath), nil
|
||||||
|
case "sftp":
|
||||||
|
// Parse options for SFTP - expected format: "user@host:/path/to/backups"
|
||||||
|
host := strategyConfig.Destination.Host
|
||||||
|
path := strategyConfig.Destination.Path
|
||||||
|
sshKey := strategyConfig.Destination.SSHKey
|
||||||
|
|
||||||
|
if host == "" {
|
||||||
|
return nil, fmt.Errorf("SFTP provider requires host in destination config")
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return nil, fmt.Errorf("SFTP provider requires path in destination config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default SSH key if not specified
|
||||||
|
if sshKey == "" {
|
||||||
|
sshKey = filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract username from host if in format user@host
|
||||||
|
username := "root" // default
|
||||||
|
if hostParts := strings.Split(host, "@"); len(hostParts) > 1 {
|
||||||
|
username = hostParts[0]
|
||||||
|
host = hostParts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewKopiaSFTPProvider(host, path, username, sshKey), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported Kopia provider: %s", strategyConfig.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBackupStrategyForService creates a backup strategy for the specified service within a group
|
||||||
|
func (f *BackupFactory) CreateBackupStrategyForService(groupName string, serviceIndex string) (Strategy, error) {
|
||||||
|
// Find service group
|
||||||
|
serviceGroup, exists := f.Config.Services[groupName]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("service group not found: %s", groupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find specific backup config within the group
|
||||||
|
backupConfig, exists := serviceGroup.BackupConfigs[serviceIndex]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("backup config not found: %s.%s", groupName, serviceIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create appropriate strategy based on type
|
||||||
|
strategyConfig := backupConfig.BackupStrategy
|
||||||
|
|
||||||
|
// Extract retention settings once
|
||||||
|
retention := Retention{
|
||||||
|
KeepLatest: strategyConfig.Retention.KeepLatest,
|
||||||
|
KeepHourly: strategyConfig.Retention.KeepHourly,
|
||||||
|
KeepDaily: strategyConfig.Retention.KeepDaily,
|
||||||
|
KeepWeekly: strategyConfig.Retention.KeepWeekly,
|
||||||
|
KeepMonthly: strategyConfig.Retention.KeepMonthly,
|
||||||
|
KeepYearly: strategyConfig.Retention.KeepYearly,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strategyConfig.Type {
|
||||||
|
case "kopia":
|
||||||
|
// Create appropriate provider based on configuration
|
||||||
|
provider, err := f.CreateKopiaProvider(strategyConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create kopia provider: %w", err)
|
||||||
|
}
|
||||||
|
return f.CreateKopiaStrategy(retention, provider), nil
|
||||||
|
case "rsync":
|
||||||
|
// Uncomment when rsync implementation is ready
|
||||||
|
// return NewRsyncStrategy(
|
||||||
|
// strategyConfig.Options,
|
||||||
|
// Destination{
|
||||||
|
// Host: strategyConfig.Destination.Host,
|
||||||
|
// Path: strategyConfig.Destination.Path,
|
||||||
|
// SSHKey: strategyConfig.Destination.SSHKey,
|
||||||
|
// },
|
||||||
|
// ), nil
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
// Default to local kopia if type is unknown or rsync (temporarily)
|
||||||
|
provider, _ := f.CreateKopiaProvider(StrategyConfig{Provider: "local"})
|
||||||
|
return f.CreateKopiaStrategy(retention, provider), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteGroupHooks runs the hooks for a service group
|
||||||
|
func (f *BackupFactory) ExecuteGroupHooks(groupName string, isBeforeHook bool) error {
|
||||||
|
serviceGroup, exists := f.Config.Services[groupName]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("service group not found: %s", groupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hook string
|
||||||
|
if isBeforeHook {
|
||||||
|
hook = serviceGroup.Hooks.BeforeHook
|
||||||
|
} else {
|
||||||
|
hook = serviceGroup.Hooks.AfterHook
|
||||||
|
}
|
||||||
|
|
||||||
|
if hook == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the hook
|
||||||
|
cmd := exec.Command("sh", "-c", hook)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hook execution failed: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Hook executed successfully: %s, output: %s", hook, string(output))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadConfig refreshes the configuration from the config file
|
||||||
|
func (f *BackupFactory) ReloadConfig() error {
|
||||||
|
config, err := LoadConfig(f.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not reload config: %w", err)
|
||||||
|
}
|
||||||
|
f.Config = config
|
||||||
|
return nil
|
||||||
|
}
|
767
internal/backup/backup_kopia.go
Normal file
767
internal/backup/backup_kopia.go
Normal file
@ -0,0 +1,767 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KopiaStrategy implements the backup strategy using Kopia
|
||||||
|
type KopiaStrategy struct {
|
||||||
|
Retention Retention
|
||||||
|
Provider KopiaProvider
|
||||||
|
ConfigPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKopiaStrategy creates a new kopia strategy with specified provider
|
||||||
|
func NewKopiaStrategy(retention Retention, provider KopiaProvider, configPath string) *KopiaStrategy {
|
||||||
|
return &KopiaStrategy{
|
||||||
|
Retention: retention,
|
||||||
|
Provider: provider,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs the kopia backup
|
||||||
|
func (s *KopiaStrategy) Execute(ctx context.Context, serviceName, directory string) error {
|
||||||
|
log.Printf("Performing kopia backup for service %s: %s", serviceName, directory)
|
||||||
|
|
||||||
|
// Get or create password for this service
|
||||||
|
password, err := getOrCreatePassword(serviceName, 48)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get or create password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Kopia config directory exists
|
||||||
|
kopiaConfigDir := filepath.Join(os.Getenv("HOME"), ".kopia")
|
||||||
|
if err := os.MkdirAll(kopiaConfigDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create kopia config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository not connected, connect or create via provider
|
||||||
|
log.Printf("Connecting to repository for %s", serviceName)
|
||||||
|
if err := s.Provider.Connect(ctx, serviceName, password, s.ConfigPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create snapshot
|
||||||
|
log.Printf("Creating snapshot for directory: %s", directory)
|
||||||
|
snapshotCmd := exec.Command("kopia", "--config-file", s.ConfigPath, "snapshot", "create", directory)
|
||||||
|
snapshotOutput, err := snapshotCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create snapshot: %w\nOutput: %s", err, snapshotOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set retention policy
|
||||||
|
log.Printf("Setting retention policy for %s", serviceName)
|
||||||
|
args := []string{
|
||||||
|
"--config-file", s.ConfigPath,
|
||||||
|
"policy", "set",
|
||||||
|
"--keep-latest", fmt.Sprintf("%d", s.Retention.KeepLatest),
|
||||||
|
"--keep-hourly", fmt.Sprintf("%d", s.Retention.KeepHourly),
|
||||||
|
"--keep-daily", fmt.Sprintf("%d", s.Retention.KeepDaily),
|
||||||
|
"--keep-weekly", fmt.Sprintf("%d", s.Retention.KeepWeekly),
|
||||||
|
"--keep-monthly", fmt.Sprintf("%d", s.Retention.KeepMonthly),
|
||||||
|
"--keep-annual", fmt.Sprintf("%d", s.Retention.KeepYearly),
|
||||||
|
directory,
|
||||||
|
}
|
||||||
|
policyCmd := exec.Command("kopia", args...)
|
||||||
|
policyOutput, err := policyCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set policy: %w\nOutput: %s", err, policyOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Snapshot and policy set successfully for %s", serviceName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBackups returns information about existing backups
|
||||||
|
func (s *KopiaStrategy) ListBackups(ctx context.Context, serviceName string) ([]BackupInfo, error) {
|
||||||
|
// Parse service group and index from service name (e.g., "backealocal.1")
|
||||||
|
groupName, _, err := parseServiceName(serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid service name format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get service directory from config
|
||||||
|
factory, err := NewBackupFactory("config.yml")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create factory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find service group
|
||||||
|
serviceGroup, exists := factory.Config.Services[groupName]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("service group not found: %s", groupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get directory from the service group level
|
||||||
|
directoryPath := strings.TrimRight(serviceGroup.Source.Path, "/")
|
||||||
|
|
||||||
|
// Ensure we're connected to the repository
|
||||||
|
err = s.EnsureRepositoryConnected(ctx, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run kopia snapshot list command with JSON output
|
||||||
|
cmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"kopia",
|
||||||
|
"--config-file", s.ConfigPath,
|
||||||
|
"snapshot",
|
||||||
|
"list",
|
||||||
|
"--json",
|
||||||
|
)
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON output
|
||||||
|
var snapshots []map[string]interface{}
|
||||||
|
if err := json.Unmarshal(output, &snapshots); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse snapshot list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to BackupInfo
|
||||||
|
var result []BackupInfo
|
||||||
|
for _, snap := range snapshots {
|
||||||
|
// Get basic info
|
||||||
|
id, _ := snap["id"].(string)
|
||||||
|
endTime, _ := snap["endTime"].(string)
|
||||||
|
|
||||||
|
// Only include snapshots for the requested service by directory path
|
||||||
|
source, ok := snap["source"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcePath, _ := source["path"].(string)
|
||||||
|
// Match by exact directory path, not service name
|
||||||
|
if sourcePath != directoryPath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse time
|
||||||
|
var creationTime time.Time
|
||||||
|
if endTime != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, endTime)
|
||||||
|
if err == nil {
|
||||||
|
creationTime = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get size information from stats
|
||||||
|
var size int64
|
||||||
|
if stats, ok := snap["stats"].(map[string]interface{}); ok {
|
||||||
|
if totalSize, ok := stats["totalSize"].(float64); ok {
|
||||||
|
size = int64(totalSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine retention tag
|
||||||
|
retentionTag := "none"
|
||||||
|
if reasons, ok := snap["retentionReason"].([]interface{}); ok && len(reasons) > 0 {
|
||||||
|
// Get the first reason which indicates the highest priority retention
|
||||||
|
if reason, ok := reasons[0].(string); ok {
|
||||||
|
parts := strings.SplitN(reason, "-", 2)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
retentionTag = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, BackupInfo{
|
||||||
|
ID: id,
|
||||||
|
CreationTime: creationTime,
|
||||||
|
Size: size,
|
||||||
|
Source: sourcePath,
|
||||||
|
Type: "kopia",
|
||||||
|
RetentionTag: retentionTag,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStorageUsage returns information about the total storage used by the repository
|
||||||
|
func (s *KopiaStrategy) GetStorageUsage(ctx context.Context, serviceName string) (*StorageUsageInfo, error) {
|
||||||
|
// Ensure we're connected to the repository
|
||||||
|
err := s.EnsureRepositoryConnected(ctx, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get provider type (b2, local, sftp)
|
||||||
|
providerType := "unknown"
|
||||||
|
switch s.Provider.(type) {
|
||||||
|
case *KopiaB2Provider:
|
||||||
|
providerType = "b2"
|
||||||
|
case *KopiaLocalProvider:
|
||||||
|
providerType = "local"
|
||||||
|
case *KopiaSFTPProvider:
|
||||||
|
providerType = "sftp"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize storage info
|
||||||
|
info := &StorageUsageInfo{
|
||||||
|
Provider: providerType,
|
||||||
|
ProviderID: s.Provider.GetBucketName(serviceName),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate logical size by summing up the sizes of all snapshots
|
||||||
|
backups, err := s.ListBackups(ctx, serviceName)
|
||||||
|
if err == nil {
|
||||||
|
var totalLogicalBytes int64
|
||||||
|
for _, backup := range backups {
|
||||||
|
totalLogicalBytes += backup.Size
|
||||||
|
}
|
||||||
|
info.TotalBytes = totalLogicalBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get physical storage stats using blob stats command
|
||||||
|
blobStatsCmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"kopia",
|
||||||
|
"--config-file", s.ConfigPath,
|
||||||
|
"blob",
|
||||||
|
"stats",
|
||||||
|
)
|
||||||
|
|
||||||
|
blobStatsOutput, err := blobStatsCmd.CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
// Parse the text output
|
||||||
|
outputStr := string(blobStatsOutput)
|
||||||
|
log.Printf("Blob stats output: %s", outputStr)
|
||||||
|
|
||||||
|
// Look for the line with "Total:"
|
||||||
|
lines := strings.Split(outputStr, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "Total:") {
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
sizeStr := strings.TrimSpace(parts[1])
|
||||||
|
size, err := parseHumanSize(sizeStr)
|
||||||
|
if err == nil {
|
||||||
|
info.TotalBytes = size
|
||||||
|
log.Printf("Got physical size from blob stats: %d bytes", size)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
log.Printf("Failed to parse size '%s': %v", sizeStr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("Blob stats command failed: %v - %s", err, string(blobStatsOutput))
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to ensure the repository is connected before operations
|
||||||
|
func (s *KopiaStrategy) EnsureRepositoryConnected(ctx context.Context, serviceName string) error {
|
||||||
|
// Check if kopia repository is already connected with config file
|
||||||
|
cmd := exec.Command("kopia", "--config-file", s.ConfigPath, "repository", "status")
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
// Repository not connected, try to connect
|
||||||
|
password, err := getOrCreatePassword(serviceName, 48)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect using provider
|
||||||
|
if err := s.Provider.Connect(ctx, serviceName, password, s.ConfigPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to repository: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseServiceName splits a service name in the format "group.index" into its components
|
||||||
|
func parseServiceName(serviceName string) (string, string, error) {
|
||||||
|
parts := strings.SplitN(serviceName, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", fmt.Errorf("service name must be in format 'group.index', got '%s'", serviceName)
|
||||||
|
}
|
||||||
|
return parts[0], parts[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOrCreatePassword retrieves a password from kopia.env or creates a new one if it doesn't exist
|
||||||
|
// Extracted to a package-level function since it's utility functionality
|
||||||
|
func getOrCreatePassword(serviceName string, length int) (string, error) {
|
||||||
|
// Define the expected key in the env file
|
||||||
|
// Replace dots with underscores for environment variable name
|
||||||
|
safeServiceName := strings.ReplaceAll(serviceName, ".", "_")
|
||||||
|
passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(safeServiceName))
|
||||||
|
|
||||||
|
// Try to read from kopia.env first
|
||||||
|
kopiaEnvPath := "kopia.env"
|
||||||
|
if _, err := os.Stat(kopiaEnvPath); err == nil {
|
||||||
|
// File exists, check if the password is already there
|
||||||
|
file, err := os.Open(kopiaEnvPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open kopia.env: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, passwordKey+"=") {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// Found the password, remove quotes if present
|
||||||
|
password := parts[1]
|
||||||
|
password = strings.Trim(password, "\"")
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return "", fmt.Errorf("error reading kopia.env: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password not found or file doesn't exist, generate a new password
|
||||||
|
password, err := generateSecurePassword(length)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or append to kopia.env
|
||||||
|
file, err := os.OpenFile(kopiaEnvPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open kopia.env for writing: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Write the new password entry with quotes
|
||||||
|
passwordEntry := fmt.Sprintf("%s=\"%s\"\n", passwordKey, password)
|
||||||
|
if _, err := file.WriteString(passwordEntry); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write to kopia.env: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Created new password for service %s and stored in kopia.env", serviceName)
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSecurePassword creates a cryptographically secure random password
|
||||||
|
// using only safe characters that work well with command line tools
|
||||||
|
func generateSecurePassword(length int) (string, error) {
|
||||||
|
// Use a more robust but safe character set
|
||||||
|
// Avoiding characters that might cause shell interpretation issues
|
||||||
|
// No quotes, backslashes, spaces, or common special chars that need escaping
|
||||||
|
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
result := make([]byte, length)
|
||||||
|
|
||||||
|
// Create a secure source of randomness
|
||||||
|
randomBytes := make([]byte, length)
|
||||||
|
if _, err := rand.Read(randomBytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure minimum complexity requirements
|
||||||
|
// At least 2 uppercase, 2 lowercase, 2 digits
|
||||||
|
if length >= 6 {
|
||||||
|
// Force first characters to meet minimum requirements
|
||||||
|
result[0] = 'A' + byte(randomBytes[0]%26) // Uppercase
|
||||||
|
result[1] = 'A' + byte(randomBytes[1]%26) // Uppercase
|
||||||
|
result[2] = 'a' + byte(randomBytes[2]%26) // Lowercase
|
||||||
|
result[3] = 'a' + byte(randomBytes[3]%26) // Lowercase
|
||||||
|
result[4] = '0' + byte(randomBytes[4]%10) // Digit
|
||||||
|
result[5] = '0' + byte(randomBytes[5]%10) // Digit
|
||||||
|
|
||||||
|
// Fill the rest with random chars
|
||||||
|
for i := 6; i < length; i++ {
|
||||||
|
result[i] = chars[int(randomBytes[i])%len(chars)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle the result to avoid predictable pattern
|
||||||
|
for i := length - 1; i > 0; i-- {
|
||||||
|
j := int(randomBytes[i]) % (i + 1)
|
||||||
|
result[i], result[j] = result[j], result[i]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For very short passwords just use random chars
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
result[i] = chars[int(randomBytes[i])%len(chars)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHumanSize parses a human-readable size string (e.g., "32.8 MB") into bytes
|
||||||
|
func parseHumanSize(sizeStr string) (int64, error) {
|
||||||
|
parts := strings.Fields(sizeStr)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, fmt.Errorf("invalid size format: %s", sizeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.ParseFloat(parts[0], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid size value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unit := strings.ToUpper(parts[1])
|
||||||
|
switch unit {
|
||||||
|
case "B":
|
||||||
|
return int64(value), nil
|
||||||
|
case "KB", "KIB":
|
||||||
|
return int64(value * 1024), nil
|
||||||
|
case "MB", "MIB":
|
||||||
|
return int64(value * 1024 * 1024), nil
|
||||||
|
case "GB", "GIB":
|
||||||
|
return int64(value * 1024 * 1024 * 1024), nil
|
||||||
|
case "TB", "TIB":
|
||||||
|
return int64(value * 1024 * 1024 * 1024 * 1024), nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unknown size unit: %s", unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreBackup restores a backup with the given ID
|
||||||
|
func (s *KopiaStrategy) RestoreBackup(ctx context.Context, backupID string, serviceName string) error {
|
||||||
|
// Ensure repository is connected
|
||||||
|
err := s.EnsureRepositoryConnected(ctx, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse service group and index from service name
|
||||||
|
groupName, _, err := parseServiceName(serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid service name format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get service directory from config
|
||||||
|
factory, err := NewBackupFactory("config.yml")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create factory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find service group
|
||||||
|
serviceGroup, exists := factory.Config.Services[groupName]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("service group not found: %s", groupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary directory for restore
|
||||||
|
restoreDir := filepath.Join(os.TempDir(), fmt.Sprintf("backea-restore-%s-%d", serviceName, time.Now().Unix()))
|
||||||
|
if err := os.MkdirAll(restoreDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create restore directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Restoring backup %s to temporary directory %s", backupID, restoreDir)
|
||||||
|
|
||||||
|
// Run kopia restore command to restore the snapshot to the temporary directory
|
||||||
|
restoreCmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"kopia",
|
||||||
|
"--config-file", s.ConfigPath,
|
||||||
|
"snapshot",
|
||||||
|
"restore",
|
||||||
|
backupID,
|
||||||
|
restoreDir,
|
||||||
|
)
|
||||||
|
|
||||||
|
restoreOutput, err := restoreCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to restore snapshot: %w\nOutput: %s", err, restoreOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now we need to sync the restored data to the original directory
|
||||||
|
targetDir := serviceGroup.Source.Path
|
||||||
|
log.Printf("Syncing restored data from %s to %s", restoreDir, targetDir)
|
||||||
|
|
||||||
|
// Use rsync for the final transfer to avoid permissions issues
|
||||||
|
syncCmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"rsync",
|
||||||
|
"-av",
|
||||||
|
"--delete", // Delete extraneous files from target
|
||||||
|
restoreDir+"/", // Source directory with trailing slash to copy contents
|
||||||
|
targetDir, // Target directory
|
||||||
|
)
|
||||||
|
|
||||||
|
syncOutput, err := syncCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to sync restored data: %w\nOutput: %s", err, syncOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the temporary directory
|
||||||
|
go func() {
|
||||||
|
time.Sleep(5 * time.Minute) // Wait 5 minutes before cleaning up
|
||||||
|
log.Printf("Cleaning up temporary restore directory %s", restoreDir)
|
||||||
|
os.RemoveAll(restoreDir)
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("Successfully restored backup %s to %s", backupID, targetDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadBackup provides a reader with backup summary information in text format
|
||||||
|
func (s *KopiaStrategy) DownloadBackup(ctx context.Context, backupID string, serviceName string) (io.ReadCloser, error) {
|
||||||
|
|
||||||
|
// Ensure repository is connected
|
||||||
|
err := s.EnsureRepositoryConnected(ctx, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary directories for restore and ZIP creation
|
||||||
|
tempDir, err := os.MkdirTemp("", "backea-download-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create temporary directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreDir := filepath.Join(tempDir, "restore")
|
||||||
|
zipFile := filepath.Join(tempDir, "backup.zip")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(restoreDir, 0755); err != nil {
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
return nil, fmt.Errorf("failed to create restore directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the snapshot to the temporary directory using the proper snapshot ID
|
||||||
|
log.Printf("Restoring snapshot %s to temporary directory %s", backupID, restoreDir)
|
||||||
|
|
||||||
|
restoreCmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"kopia",
|
||||||
|
"--config-file", s.ConfigPath,
|
||||||
|
"snapshot",
|
||||||
|
"restore",
|
||||||
|
backupID,
|
||||||
|
restoreDir,
|
||||||
|
)
|
||||||
|
|
||||||
|
restoreOutput, err := restoreCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
return nil, fmt.Errorf("failed to restore snapshot: %w\nOutput: %s", err, restoreOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ZIP archive of the restored files
|
||||||
|
log.Printf("Creating ZIP archive at %s", zipFile)
|
||||||
|
|
||||||
|
// Use Go's zip package instead of command line tools
|
||||||
|
zipWriter, err := os.Create(zipFile)
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
return nil, fmt.Errorf("failed to create zip file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer zipWriter.Close()
|
||||||
|
|
||||||
|
// Create ZIP writer
|
||||||
|
archive := zip.NewWriter(zipWriter)
|
||||||
|
defer archive.Close()
|
||||||
|
|
||||||
|
// Walk the restore directory and add files to ZIP
|
||||||
|
err = filepath.Walk(restoreDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories, we only want to add files
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ZIP header
|
||||||
|
header, err := zip.FileInfoHeader(info)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the path relative to the restore directory
|
||||||
|
relPath, err := filepath.Rel(restoreDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the name in the archive to the relative path
|
||||||
|
header.Name = relPath
|
||||||
|
|
||||||
|
// Create the file in the ZIP
|
||||||
|
writer, err := archive.CreateHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the file
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Copy the file content to the ZIP
|
||||||
|
_, err = io.Copy(writer, file)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close the ZIP writer
|
||||||
|
archive.Close()
|
||||||
|
zipWriter.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
return nil, fmt.Errorf("failed to create zip archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the ZIP file for reading
|
||||||
|
zipReader, err := os.Open(zipFile)
|
||||||
|
if err != nil {
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
return nil, fmt.Errorf("failed to open zip archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a reader that will clean up when closed
|
||||||
|
return &cleanupReadCloser{
|
||||||
|
ReadCloser: zipReader,
|
||||||
|
cleanup: func() {
|
||||||
|
zipReader.Close()
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBackupInfo returns detailed information about a specific backup
|
||||||
|
func (s *KopiaStrategy) GetBackupInfo(ctx context.Context, backupID string, serviceName string) (*BackupInfo, error) {
|
||||||
|
|
||||||
|
err := s.EnsureRepositoryConnected(ctx, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run kopia snapshot describe command with JSON output
|
||||||
|
cmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"kopia",
|
||||||
|
"--config-file", s.ConfigPath,
|
||||||
|
"snapshot",
|
||||||
|
"describe",
|
||||||
|
"--json",
|
||||||
|
backupID,
|
||||||
|
)
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to describe snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON output
|
||||||
|
var snap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(output, &snap); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse snapshot info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the relevant information
|
||||||
|
id, _ := snap["id"].(string)
|
||||||
|
endTime, _ := snap["endTime"].(string)
|
||||||
|
|
||||||
|
// Parse time
|
||||||
|
var creationTime time.Time
|
||||||
|
if endTime != "" {
|
||||||
|
t, err := time.Parse(time.RFC3339, endTime)
|
||||||
|
if err == nil {
|
||||||
|
creationTime = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get size information from stats
|
||||||
|
var size int64
|
||||||
|
if stats, ok := snap["stats"].(map[string]interface{}); ok {
|
||||||
|
if totalSize, ok := stats["totalSize"].(float64); ok {
|
||||||
|
size = int64(totalSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get source path
|
||||||
|
sourcePath := ""
|
||||||
|
if source, ok := snap["source"].(map[string]interface{}); ok {
|
||||||
|
if path, ok := source["path"].(string); ok {
|
||||||
|
sourcePath = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine retention tag
|
||||||
|
retentionTag := "none"
|
||||||
|
if reasons, ok := snap["retentionReason"].([]interface{}); ok && len(reasons) > 0 {
|
||||||
|
// Get the first reason which indicates the highest priority retention
|
||||||
|
if reason, ok := reasons[0].(string); ok {
|
||||||
|
parts := strings.SplitN(reason, "-", 2)
|
||||||
|
if len(parts) > 0 {
|
||||||
|
retentionTag = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &BackupInfo{
|
||||||
|
ID: id,
|
||||||
|
CreationTime: creationTime,
|
||||||
|
Size: size,
|
||||||
|
Source: sourcePath,
|
||||||
|
Type: "kopia",
|
||||||
|
RetentionTag: retentionTag,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupReadCloser is a wrapper around io.ReadCloser that performs cleanup when closed
|
||||||
|
type cleanupReadCloser struct {
|
||||||
|
io.ReadCloser
|
||||||
|
cleanup func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying ReadCloser and performs cleanup
|
||||||
|
func (c *cleanupReadCloser) Close() error {
|
||||||
|
err := c.ReadCloser.Close()
|
||||||
|
c.cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractServiceNameFromBackupID extracts the service name from a backup ID
|
||||||
|
// This is an approximation as the exact format depends on your ID structure
|
||||||
|
func extractServiceNameFromBackupID(backupID string) string {
|
||||||
|
// Kopia snapshot IDs don't directly include the service name
|
||||||
|
// You may need to adjust this based on your actual ID format
|
||||||
|
|
||||||
|
// Try to extract the pattern from your backups list
|
||||||
|
// If you're using ListBackups to find all backups, you might have
|
||||||
|
// a mapping of IDs to service names that you can use
|
||||||
|
|
||||||
|
// For this example, let's assume we're using environment variables to track this
|
||||||
|
envVar := fmt.Sprintf("BACKEA_SNAPSHOT_%s", backupID)
|
||||||
|
if serviceName := os.Getenv(envVar); serviceName != "" {
|
||||||
|
return serviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: For testing, we'll return a default or parse from the first part
|
||||||
|
// This should be replaced with your actual logic
|
||||||
|
parts := strings.Split(backupID, "-")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
}
|
51
internal/backup/backup_rsync.go
Normal file
51
internal/backup/backup_rsync.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RsyncStrategy implements the backup strategy using rsync
|
||||||
|
type RsyncStrategy struct {
|
||||||
|
Options string
|
||||||
|
Destination Destination
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRsyncStrategy creates a new rsync strategy
|
||||||
|
func NewRsyncStrategy(options string, destination Destination) *RsyncStrategy {
|
||||||
|
return &RsyncStrategy{
|
||||||
|
Options: options,
|
||||||
|
Destination: destination,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute performs the rsync backup
|
||||||
|
func (s *RsyncStrategy) Execute(ctx context.Context, serviceName, directory string) error {
|
||||||
|
log.Printf("Performing rsync backup for %s", serviceName)
|
||||||
|
|
||||||
|
// Build rsync command
|
||||||
|
dst := s.Destination
|
||||||
|
sshKeyOption := ""
|
||||||
|
|
||||||
|
// Without
|
||||||
|
if dst.SSHKey != "" {
|
||||||
|
sshKeyOption = fmt.Sprintf("-e 'ssh -i %s'", dst.SSHKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
destination := fmt.Sprintf("%s:%s", dst.Host, dst.Path)
|
||||||
|
rsyncCmd := fmt.Sprintf("rsync %s %s %s %s",
|
||||||
|
s.Options,
|
||||||
|
sshKeyOption,
|
||||||
|
directory,
|
||||||
|
destination)
|
||||||
|
|
||||||
|
// Run rsync command
|
||||||
|
log.Printf("Running: %s", rsyncCmd)
|
||||||
|
cmd := exec.CommandContext(ctx, "sh", "-c", rsyncCmd)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
112
internal/backup/config.go
Normal file
112
internal/backup/config.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Configuration holds the complete application configuration
|
||||||
|
type Configuration struct {
|
||||||
|
Defaults DefaultsConfig `mapstructure:"defaults"`
|
||||||
|
Services map[string]ServiceGroup `mapstructure:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultsConfig holds the default settings for all services
|
||||||
|
type DefaultsConfig struct {
|
||||||
|
Retention RetentionConfig `mapstructure:"retention"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceGroup represents a service group with a common directory and multiple backup configurations
|
||||||
|
type ServiceGroup struct {
|
||||||
|
Directory string `mapstructure:"directory,omitempty"`
|
||||||
|
Source SourceConfig `mapstructure:"source"`
|
||||||
|
Hooks HooksConfig `mapstructure:"hooks"`
|
||||||
|
BackupConfigs map[string]BackupConfig `mapstructure:"backup_configs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceConfig represents source configuration for backups
|
||||||
|
type SourceConfig struct {
|
||||||
|
Host string `mapstructure:"host"`
|
||||||
|
Path string `mapstructure:"path"`
|
||||||
|
SSHKey string `mapstructure:"ssh_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HooksConfig contains the hooks to run before and after the backup
|
||||||
|
type HooksConfig struct {
|
||||||
|
BeforeHook string `mapstructure:"before_hook"`
|
||||||
|
AfterHook string `mapstructure:"after_hook"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupConfig represents a specific backup configuration
|
||||||
|
type BackupConfig struct {
|
||||||
|
BackupStrategy StrategyConfig `mapstructure:"backup_strategy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrategyConfig represents a backup strategy configuration
|
||||||
|
type StrategyConfig struct {
|
||||||
|
Type string `mapstructure:"type"`
|
||||||
|
Provider string `mapstructure:"provider"`
|
||||||
|
Options string `mapstructure:"options,omitempty"`
|
||||||
|
Retention RetentionConfig `mapstructure:"retention,omitempty"`
|
||||||
|
Destination DestConfig `mapstructure:"destination"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetentionConfig represents retention policy configuration
|
||||||
|
type RetentionConfig struct {
|
||||||
|
KeepLatest int `mapstructure:"keep_latest"`
|
||||||
|
KeepHourly int `mapstructure:"keep_hourly"`
|
||||||
|
KeepDaily int `mapstructure:"keep_daily"`
|
||||||
|
KeepWeekly int `mapstructure:"keep_weekly"`
|
||||||
|
KeepMonthly int `mapstructure:"keep_monthly"`
|
||||||
|
KeepYearly int `mapstructure:"keep_yearly"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestConfig represents destination configuration for remote backups
|
||||||
|
type DestConfig struct {
|
||||||
|
Host string `mapstructure:"host,omitempty"`
|
||||||
|
Path string `mapstructure:"path"`
|
||||||
|
SSHKey string `mapstructure:"ssh_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads the application configuration from a file
|
||||||
|
func LoadConfig(configPath string) (*Configuration, error) {
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
log.Printf("Warning: Error loading .env file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
viper.SetConfigFile(configPath)
|
||||||
|
if err := viper.ReadInConfig(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var config Configuration
|
||||||
|
if err := viper.Unmarshal(&config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply default retention settings where needed
|
||||||
|
for serviceName, service := range config.Services {
|
||||||
|
for configID, backupConfig := range service.BackupConfigs {
|
||||||
|
// If retention is not set, use defaults
|
||||||
|
if isRetentionEmpty(backupConfig.BackupStrategy.Retention) {
|
||||||
|
backupConfig.BackupStrategy.Retention = config.Defaults.Retention
|
||||||
|
service.BackupConfigs[configID] = backupConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.Services[serviceName] = service
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRetentionEmpty checks if a retention config is empty (all zeros)
|
||||||
|
func isRetentionEmpty(retention RetentionConfig) bool {
|
||||||
|
return retention.KeepLatest == 0 &&
|
||||||
|
retention.KeepHourly == 0 &&
|
||||||
|
retention.KeepDaily == 0 &&
|
||||||
|
retention.KeepWeekly == 0 &&
|
||||||
|
retention.KeepMonthly == 0 &&
|
||||||
|
retention.KeepYearly == 0
|
||||||
|
}
|
277
internal/backup/kopia_providers.go
Normal file
277
internal/backup/kopia_providers.go
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
b2_client "backea/internal/client"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KopiaProvider defines the interface for different Kopia storage backends
|
||||||
|
type KopiaProvider interface {
|
||||||
|
// Connect connects to an existing repository or creates a new one
|
||||||
|
Connect(ctx context.Context, serviceName string, password string, configPath string) error
|
||||||
|
// GetRepositoryParams returns the parameters needed for repository operations
|
||||||
|
GetRepositoryParams(serviceName string) ([]string, error)
|
||||||
|
// GetBucketName returns the storage identifier (bucket name or path)
|
||||||
|
GetBucketName(serviceName string) string
|
||||||
|
// GetProviderType returns a string identifying the provider type
|
||||||
|
GetProviderType() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// KopiaB2Provider implements the KopiaProvider interface for Backblaze B2
|
||||||
|
type KopiaB2Provider struct{}
|
||||||
|
|
||||||
|
// NewKopiaB2Provider creates a new B2 provider
|
||||||
|
func NewKopiaB2Provider() *KopiaB2Provider {
|
||||||
|
return &KopiaB2Provider{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect connects to a B2 repository
|
||||||
|
func (p *KopiaB2Provider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
|
||||||
|
// Load environment variables for B2 credentials
|
||||||
|
keyID := os.Getenv("B2_KEY_ID")
|
||||||
|
applicationKey := os.Getenv("B2_APPLICATION_KEY")
|
||||||
|
if keyID == "" || applicationKey == "" {
|
||||||
|
return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketName := p.GetBucketName(serviceName)
|
||||||
|
|
||||||
|
// Create B2 client and check if bucket exists, create if not
|
||||||
|
B2Client, _ := b2_client.NewClientFromEnv()
|
||||||
|
if B2Client == nil {
|
||||||
|
return fmt.Errorf("B2 client not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := B2Client.GetBucket(ctx, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Bucket %s not found, creating...", bucketName)
|
||||||
|
_, err = B2Client.CreateBucket(ctx, bucketName, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create bucket: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("Created bucket: %s", bucketName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to connect to existing repository with config file
|
||||||
|
connectCmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"kopia",
|
||||||
|
"--config-file", configPath,
|
||||||
|
"repository", "connect", "b2",
|
||||||
|
"--bucket", bucketName,
|
||||||
|
"--key-id", keyID,
|
||||||
|
"--key", applicationKey,
|
||||||
|
"--password", password,
|
||||||
|
)
|
||||||
|
err = connectCmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
// Connection failed, create new repository
|
||||||
|
log.Printf("Creating new B2 repository for %s", serviceName)
|
||||||
|
createCmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"kopia",
|
||||||
|
"--config-file", configPath,
|
||||||
|
"repository", "create", "b2",
|
||||||
|
"--bucket", bucketName,
|
||||||
|
"--key-id", keyID,
|
||||||
|
"--key", applicationKey,
|
||||||
|
"--password", password,
|
||||||
|
)
|
||||||
|
createOutput, err := createCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepositoryParams returns parameters for B2 operations
|
||||||
|
func (p *KopiaB2Provider) GetRepositoryParams(serviceName string) ([]string, error) {
|
||||||
|
keyID := os.Getenv("B2_KEY_ID")
|
||||||
|
applicationKey := os.Getenv("B2_APPLICATION_KEY")
|
||||||
|
if keyID == "" || applicationKey == "" {
|
||||||
|
return nil, fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketName := p.GetBucketName(serviceName)
|
||||||
|
|
||||||
|
return []string{
|
||||||
|
"b2",
|
||||||
|
"--bucket", bucketName,
|
||||||
|
"--key-id", keyID,
|
||||||
|
"--key", applicationKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modify the GetBucketName method in your KopiaB2Provider
|
||||||
|
func (p *KopiaB2Provider) GetBucketName(serviceName string) string {
|
||||||
|
// Backblaze dont allow .
|
||||||
|
sanitized := strings.ReplaceAll(serviceName, ".", "-")
|
||||||
|
|
||||||
|
// Add a prefix to make the bucket name unique
|
||||||
|
return fmt.Sprintf("backea-%s", sanitized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProviderType returns the provider type identifier
|
||||||
|
func (p *KopiaB2Provider) GetProviderType() string {
|
||||||
|
return "b2"
|
||||||
|
}
|
||||||
|
|
||||||
|
// KopiaLocalProvider implements the KopiaProvider interface for local storage
|
||||||
|
type KopiaLocalProvider struct {
|
||||||
|
BasePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKopiaLocalProvider creates a new local provider
|
||||||
|
func NewKopiaLocalProvider(basePath string) *KopiaLocalProvider {
|
||||||
|
// If basePath is empty, use a default location
|
||||||
|
if basePath == "" {
|
||||||
|
basePath = filepath.Join(os.Getenv("HOME"), ".backea", "repos")
|
||||||
|
}
|
||||||
|
return &KopiaLocalProvider{
|
||||||
|
BasePath: basePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect connects to a local repository
|
||||||
|
func (p *KopiaLocalProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
|
||||||
|
repoPath := filepath.Join(p.BasePath, serviceName)
|
||||||
|
log.Printf("Connecting to local repository at %s with config: %s", repoPath, configPath)
|
||||||
|
|
||||||
|
// Ensure the directory exists
|
||||||
|
if err := os.MkdirAll(repoPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create repository directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to connect to existing repository with config file
|
||||||
|
connectCmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"kopia",
|
||||||
|
"--config-file", configPath,
|
||||||
|
"repository", "connect", "filesystem",
|
||||||
|
"--path", repoPath,
|
||||||
|
"--password", password,
|
||||||
|
)
|
||||||
|
err := connectCmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
// Connection failed, create new repository
|
||||||
|
log.Printf("Creating new local repository for %s at destination: %s", serviceName, repoPath)
|
||||||
|
createCmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"kopia",
|
||||||
|
"--config-file", configPath,
|
||||||
|
"repository", "create", "filesystem",
|
||||||
|
"--path", repoPath,
|
||||||
|
"--password", password,
|
||||||
|
)
|
||||||
|
createOutput, err := createCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepositoryParams returns parameters for local filesystem operations
|
||||||
|
func (p *KopiaLocalProvider) GetRepositoryParams(serviceName string) ([]string, error) {
|
||||||
|
repoPath := filepath.Join(p.BasePath, serviceName)
|
||||||
|
return []string{
|
||||||
|
"filesystem",
|
||||||
|
"--path", repoPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBucketName returns the path for a service
|
||||||
|
func (p *KopiaLocalProvider) GetBucketName(serviceName string) string {
|
||||||
|
return filepath.Join(p.BasePath, serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProviderType returns the provider type identifier
|
||||||
|
func (p *KopiaLocalProvider) GetProviderType() string {
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
// KopiaSFTPProvider implements the KopiaProvider interface for SFTP remote storage
|
||||||
|
type KopiaSFTPProvider struct {
|
||||||
|
Host string
|
||||||
|
BasePath string
|
||||||
|
Username string
|
||||||
|
KeyFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKopiaSFTPProvider creates a new SFTP provider
|
||||||
|
func NewKopiaSFTPProvider(host, basePath, username, keyFile string) *KopiaSFTPProvider {
|
||||||
|
return &KopiaSFTPProvider{
|
||||||
|
Host: host,
|
||||||
|
BasePath: basePath,
|
||||||
|
Username: username,
|
||||||
|
KeyFile: keyFile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect connects to an SFTP repository
|
||||||
|
func (p *KopiaSFTPProvider) Connect(ctx context.Context, serviceName string, password string, configPath string) error {
|
||||||
|
repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName)
|
||||||
|
|
||||||
|
// Try to connect to existing repository with config file
|
||||||
|
connectCmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"kopia",
|
||||||
|
"--config-file", configPath,
|
||||||
|
"repository", "connect", "sftp",
|
||||||
|
"--path", repoPath,
|
||||||
|
"--keyfile", p.KeyFile,
|
||||||
|
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
|
||||||
|
"--password", password,
|
||||||
|
)
|
||||||
|
err := connectCmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
// Connection failed, create new repository
|
||||||
|
log.Printf("Creating new SFTP repository for %s at %s", serviceName, repoPath)
|
||||||
|
createCmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
|
"kopia",
|
||||||
|
"--config-file", configPath,
|
||||||
|
"repository", "create", "sftp",
|
||||||
|
"--path", repoPath,
|
||||||
|
"--keyfile", p.KeyFile,
|
||||||
|
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
|
||||||
|
"--password", password,
|
||||||
|
)
|
||||||
|
createOutput, err := createCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create repository: %w\nOutput: %s", err, createOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepositoryParams returns parameters for SFTP operations
|
||||||
|
func (p *KopiaSFTPProvider) GetRepositoryParams(serviceName string) ([]string, error) {
|
||||||
|
repoPath := fmt.Sprintf("%s@%s:%s/%s", p.Username, p.Host, p.BasePath, serviceName)
|
||||||
|
return []string{
|
||||||
|
"sftp",
|
||||||
|
"--path", repoPath,
|
||||||
|
"--keyfile", p.KeyFile,
|
||||||
|
"--known-hosts", filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBucketName returns the path for a service
|
||||||
|
func (p *KopiaSFTPProvider) GetBucketName(serviceName string) string {
|
||||||
|
return fmt.Sprintf("%s/%s", p.BasePath, serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProviderType returns the provider type identifier
|
||||||
|
func (p *KopiaSFTPProvider) GetProviderType() string {
|
||||||
|
return "sftp"
|
||||||
|
}
|
179
internal/backup/restore.go
Normal file
179
internal/backup/restore.go
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
client "backea/internal/client"
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestoreManager handles restoring files from Kopia backups
|
||||||
|
type RestoreManager struct {
|
||||||
|
B2Client *client.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRestoreManager creates a new restore manager
|
||||||
|
func NewRestoreManager(b2Client *client.Client) *RestoreManager {
|
||||||
|
return &RestoreManager{
|
||||||
|
B2Client: b2Client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStoredPassword retrieves a password from kopia.env file
|
||||||
|
func (r *RestoreManager) getStoredPassword(serviceName string) (string, error) {
|
||||||
|
// Define the expected key in the env file
|
||||||
|
passwordKey := fmt.Sprintf("KOPIA_%s_PASSWORD", strings.ToUpper(serviceName))
|
||||||
|
|
||||||
|
// Try to read from kopia.env
|
||||||
|
kopiaEnvPath := "kopia.env"
|
||||||
|
if _, err := os.Stat(kopiaEnvPath); err != nil {
|
||||||
|
return "", fmt.Errorf("kopia.env file not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File exists, check if the password is there
|
||||||
|
file, err := os.Open(kopiaEnvPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open kopia.env: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, passwordKey+"=") {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// Found the password, remove quotes if present
|
||||||
|
password := parts[1]
|
||||||
|
password = strings.Trim(password, "\"")
|
||||||
|
return password, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return "", fmt.Errorf("error reading kopia.env: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("password for service %s not found in kopia.env", serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectToRepository connects to an existing Kopia repository
|
||||||
|
func (r *RestoreManager) connectToRepository(ctx context.Context, serviceName string) error {
|
||||||
|
if r.B2Client == nil {
|
||||||
|
return fmt.Errorf("B2 client not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load environment variables for B2 credentials
|
||||||
|
keyID := os.Getenv("B2_KEY_ID")
|
||||||
|
applicationKey := os.Getenv("B2_APPLICATION_KEY")
|
||||||
|
if keyID == "" || applicationKey == "" {
|
||||||
|
return fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stored password for this service
|
||||||
|
password, err := r.getStoredPassword(serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get stored password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate bucket name from service name
|
||||||
|
bucketName := fmt.Sprintf("backea-%s", strings.ToLower(serviceName))
|
||||||
|
|
||||||
|
// Check if bucket exists
|
||||||
|
_, err = r.B2Client.GetBucket(ctx, bucketName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bucket %s not found: %w", bucketName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if kopia repository is already connected
|
||||||
|
cmd := exec.Command("kopia", "repository", "status")
|
||||||
|
err = cmd.Run()
|
||||||
|
if err == nil {
|
||||||
|
// Already connected
|
||||||
|
log.Printf("Already connected to a repository, disconnecting first")
|
||||||
|
disconnectCmd := exec.Command("kopia", "repository", "disconnect")
|
||||||
|
if err := disconnectCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("failed to disconnect from current repository: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to the repository
|
||||||
|
log.Printf("Connecting to B2 repository for %s", serviceName)
|
||||||
|
connectCmd := exec.Command(
|
||||||
|
"kopia", "repository", "connect", "b2",
|
||||||
|
"--bucket", bucketName,
|
||||||
|
"--key-id", keyID,
|
||||||
|
"--key", applicationKey,
|
||||||
|
"--password", password,
|
||||||
|
)
|
||||||
|
connectOutput, err := connectCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to repository: %w\nOutput: %s", err, connectOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Successfully connected to repository for %s", serviceName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSnapshots lists all snapshots in the repository for a given service
|
||||||
|
func (r *RestoreManager) ListSnapshots(ctx context.Context, serviceName string) error {
|
||||||
|
// Connect to the repository
|
||||||
|
if err := r.connectToRepository(ctx, serviceName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all snapshots
|
||||||
|
log.Printf("Listing snapshots for %s", serviceName)
|
||||||
|
listCmd := exec.Command("kopia", "snapshot", "list")
|
||||||
|
listOutput, err := listCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list snapshots: %w\nOutput: %s", err, listOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print the output
|
||||||
|
fmt.Println("Available snapshots:")
|
||||||
|
fmt.Println(string(listOutput))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreFile restores a file or directory from a specific snapshot to a target location
|
||||||
|
func (r *RestoreManager) RestoreFile(ctx context.Context, serviceName, snapshotID, sourcePath, targetPath string) error {
|
||||||
|
// Connect to the repository
|
||||||
|
if err := r.connectToRepository(ctx, serviceName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a subdirectory with the snapshot ID name
|
||||||
|
snapshotDirPath := filepath.Join(targetPath, snapshotID)
|
||||||
|
if err := os.MkdirAll(snapshotDirPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create target directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the kopia restore command
|
||||||
|
log.Printf("Restoring from snapshot %s to %s", snapshotID, snapshotDirPath)
|
||||||
|
|
||||||
|
// Build the command with all required parameters
|
||||||
|
restoreCmd := exec.Command(
|
||||||
|
"kopia", "snapshot", "restore",
|
||||||
|
snapshotID, // Just the snapshot ID
|
||||||
|
snapshotDirPath, // Target location where files will be restored
|
||||||
|
)
|
||||||
|
|
||||||
|
// Execute the command and capture output
|
||||||
|
restoreOutput, err := restoreCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to restore files: %w\nOutput: %s", err, restoreOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Successfully restored files to %s", snapshotDirPath)
|
||||||
|
log.Printf("Restore output: %s", string(restoreOutput))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
71
internal/backup/service.go
Normal file
71
internal/backup/service.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backea/internal/mail"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service represents a service to be backed up
|
||||||
|
type Service struct {
|
||||||
|
Name string
|
||||||
|
Directory string
|
||||||
|
Strategy Strategy
|
||||||
|
Mailer *mail.Mailer
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new backup service
|
||||||
|
func NewService(name string, directory string, strategy Strategy, mailer *mail.Mailer) *Service {
|
||||||
|
return &Service{
|
||||||
|
Name: name,
|
||||||
|
Directory: directory,
|
||||||
|
Strategy: strategy,
|
||||||
|
Mailer: mailer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup performs the backup for this service
|
||||||
|
func (s *Service) Backup(ctx context.Context) error {
|
||||||
|
log.Printf("Backing up service: %s", s.Name)
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if _, err := os.Stat(s.Directory); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("directory does not exist: %s", s.Directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the backup strategy
|
||||||
|
if err := s.Strategy.Execute(ctx, s.Name, s.Directory); err != nil {
|
||||||
|
if s.Mailer != nil {
|
||||||
|
s.Mailer.SendErrorNotification(s.Name, err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("backup failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record backup completion
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
log.Printf("Backup completed for %s in %v", s.Name, duration)
|
||||||
|
|
||||||
|
// Send success notification
|
||||||
|
if s.Mailer != nil {
|
||||||
|
s.Mailer.SendSuccessNotification(s.Name, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCommand executes a shell command in the specified directory
|
||||||
|
// This is now a package function rather than a method on Service
|
||||||
|
func RunCommand(dir, command string) error {
|
||||||
|
cmd := exec.Command("sh", "-c", command)
|
||||||
|
if dir != "" {
|
||||||
|
cmd.Dir = dir
|
||||||
|
}
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
57
internal/backup/strategy.go
Normal file
57
internal/backup/strategy.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Strategy defines the backup/restore strategy interface
|
||||||
|
type Strategy interface {
|
||||||
|
// Execute performs a backup
|
||||||
|
Execute(ctx context.Context, serviceName, directory string) error
|
||||||
|
|
||||||
|
ListBackups(ctx context.Context, serviceName string) ([]BackupInfo, error)
|
||||||
|
|
||||||
|
GetStorageUsage(ctx context.Context, serviceName string) (*StorageUsageInfo, error)
|
||||||
|
|
||||||
|
RestoreBackup(ctx context.Context, backupID string, serviceName string) error
|
||||||
|
|
||||||
|
DownloadBackup(ctx context.Context, backupID, serviceName string) (io.ReadCloser, error)
|
||||||
|
|
||||||
|
GetBackupInfo(context.Context, string, string) (*BackupInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupInfo contains information about a single backup
|
||||||
|
type BackupInfo struct {
|
||||||
|
ID string // Unique identifier for the backup
|
||||||
|
CreationTime time.Time // When the backup was created
|
||||||
|
Size int64 // Size in bytes
|
||||||
|
Source string // Original source path
|
||||||
|
Type string // Type of backup (e.g., "kopia", "restic")
|
||||||
|
RetentionTag string // Retention policy applied (e.g., "latest", "daily", "weekly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageUsageInfo contains information about backup storage usage
|
||||||
|
type StorageUsageInfo struct {
|
||||||
|
TotalBytes int64 // Total bytes stored
|
||||||
|
Provider string // Storage provider (e.g., "local", "b2", "s3")
|
||||||
|
ProviderID string // Provider-specific ID (bucket name, path, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retention represents retention policy for backups
|
||||||
|
type Retention struct {
|
||||||
|
KeepLatest int
|
||||||
|
KeepHourly int
|
||||||
|
KeepDaily int
|
||||||
|
KeepWeekly int
|
||||||
|
KeepMonthly int
|
||||||
|
KeepYearly int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destination represents rsync destination
|
||||||
|
type Destination struct {
|
||||||
|
Host string
|
||||||
|
Path string
|
||||||
|
SSHKey string
|
||||||
|
}
|
91
internal/client/b2_client.go
Normal file
91
internal/client/b2_client.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package b2_client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"gopkg.in/kothar/go-backblaze.v0"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client represents a B2 client
|
||||||
|
type Client struct {
|
||||||
|
b2 *backblaze.B2
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientFromEnv creates a new B2 client using credentials from .env
|
||||||
|
func NewClientFromEnv() (*Client, error) {
|
||||||
|
// Load .env file
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error loading .env file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get credentials from environment
|
||||||
|
keyID := os.Getenv("B2_KEY_ID")
|
||||||
|
applicationKey := os.Getenv("B2_APPLICATION_KEY")
|
||||||
|
|
||||||
|
if keyID == "" || applicationKey == "" {
|
||||||
|
return nil, fmt.Errorf("B2_KEY_ID and B2_APPLICATION_KEY must be set in .env file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create B2 client
|
||||||
|
b2, err := backblaze.NewB2(backblaze.Credentials{
|
||||||
|
AccountID: keyID,
|
||||||
|
ApplicationKey: applicationKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error creating B2 client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
b2: b2,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBuckets lists all buckets
|
||||||
|
func (c *Client) ListBuckets(ctx context.Context) ([]*backblaze.Bucket, error) {
|
||||||
|
// List all buckets
|
||||||
|
buckets, err := c.b2.ListBuckets()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error listing buckets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBucket gets a bucket by name
|
||||||
|
func (c *Client) GetBucket(ctx context.Context, name string) (*backblaze.Bucket, error) {
|
||||||
|
// List all buckets
|
||||||
|
buckets, err := c.b2.ListBuckets()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error listing buckets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find bucket by name
|
||||||
|
for _, bucket := range buckets {
|
||||||
|
if bucket.Name == name {
|
||||||
|
return bucket, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("bucket not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBucket creates a new bucket
|
||||||
|
func (c *Client) CreateBucket(ctx context.Context, name string, public bool) (*backblaze.Bucket, error) {
|
||||||
|
// Set bucket type
|
||||||
|
bucketType := backblaze.AllPrivate
|
||||||
|
if public {
|
||||||
|
bucketType = backblaze.AllPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create bucket
|
||||||
|
bucket, err := c.b2.CreateBucket(name, bucketType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error creating bucket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket, nil
|
||||||
|
}
|
104
internal/mail/mail.go
Normal file
104
internal/mail/mail.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds SMTP server configuration
|
||||||
|
type Config struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
From string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client handles email operations
|
||||||
|
type Client struct {
|
||||||
|
Config Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mailer is an alias for Client to maintain compatibility with backup package
|
||||||
|
type Mailer struct {
|
||||||
|
Config Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new mail client from environment variables
|
||||||
|
func NewClient() *Client {
|
||||||
|
return &Client{
|
||||||
|
Config: Config{
|
||||||
|
Host: os.Getenv("SMTP_HOST"),
|
||||||
|
Port: os.Getenv("SMTP_PORT"),
|
||||||
|
Username: os.Getenv("SMTP_USERNAME"),
|
||||||
|
Password: os.Getenv("SMTP_PASSWORD"),
|
||||||
|
From: os.Getenv("SMTP_FROM"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMailer creates a new mailer from environment variables
|
||||||
|
func NewMailer() *Mailer {
|
||||||
|
client := NewClient()
|
||||||
|
return &Mailer{
|
||||||
|
Config: client.Config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMail sends an email notification
|
||||||
|
func (c *Client) SendMail(to []string, subject, body string) error {
|
||||||
|
addr := fmt.Sprintf("%s:%s", c.Config.Host, c.Config.Port)
|
||||||
|
// Compose message
|
||||||
|
message := []byte(fmt.Sprintf("From: %s\r\n"+
|
||||||
|
"To: %s\r\n"+
|
||||||
|
"Subject: %s\r\n"+
|
||||||
|
"\r\n"+
|
||||||
|
"%s\r\n", c.Config.From, to[0], subject, body))
|
||||||
|
// Authenticate
|
||||||
|
auth := smtp.PlainAuth("", c.Config.Username, c.Config.Password, c.Config.Host)
|
||||||
|
// Send mail
|
||||||
|
return smtp.SendMail(addr, auth, c.Config.From, to, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMail sends an email notification (Mailer version)
|
||||||
|
func (m *Mailer) SendMail(to []string, subject, body string) error {
|
||||||
|
addr := fmt.Sprintf("%s:%s", m.Config.Host, m.Config.Port)
|
||||||
|
// Compose message
|
||||||
|
message := []byte(fmt.Sprintf("From: %s\r\n"+
|
||||||
|
"To: %s\r\n"+
|
||||||
|
"Subject: %s\r\n"+
|
||||||
|
"\r\n"+
|
||||||
|
"%s\r\n", m.Config.From, to[0], subject, body))
|
||||||
|
// Authenticate
|
||||||
|
auth := smtp.PlainAuth("", m.Config.Username, m.Config.Password, m.Config.Host)
|
||||||
|
// Send mail
|
||||||
|
return smtp.SendMail(addr, auth, m.Config.From, to, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendSuccessNotification sends a backup success notification
|
||||||
|
func (m *Mailer) SendSuccessNotification(serviceName string, duration time.Duration) error {
|
||||||
|
recipients := []string{os.Getenv("NOTIFICATION_EMAIL")}
|
||||||
|
if recipients[0] == "" {
|
||||||
|
recipients[0] = m.Config.From // Fallback to sender if no notification email set
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := fmt.Sprintf("Backup Successful: %s", serviceName)
|
||||||
|
body := fmt.Sprintf("The backup for service %s completed successfully in %v.", serviceName, duration)
|
||||||
|
|
||||||
|
return m.SendMail(recipients, subject, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendErrorNotification sends a backup error notification
|
||||||
|
func (m *Mailer) SendErrorNotification(serviceName string, err error) error {
|
||||||
|
recipients := []string{os.Getenv("NOTIFICATION_EMAIL")}
|
||||||
|
if recipients[0] == "" {
|
||||||
|
recipients[0] = m.Config.From // Fallback to sender if no notification email set
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := fmt.Sprintf("Backup Failed: %s", serviceName)
|
||||||
|
body := fmt.Sprintf("The backup for service %s failed with error: %v", serviceName, err)
|
||||||
|
|
||||||
|
return m.SendMail(recipients, subject, body)
|
||||||
|
}
|
39
internal/mail/mail_test.go
Normal file
39
internal/mail/mail_test.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSendMailWithRealEnv(t *testing.T) {
|
||||||
|
// Load the real .env file
|
||||||
|
err := godotenv.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test: .env file not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SMTP configuration exists
|
||||||
|
host := os.Getenv("SMTP_HOST")
|
||||||
|
username := os.Getenv("SMTP_USERNAME")
|
||||||
|
password := os.Getenv("SMTP_PASSWORD")
|
||||||
|
|
||||||
|
if host == "" || username == "" || password == "" {
|
||||||
|
t.Skip("Skipping test: SMTP configuration not found in .env")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a real client using environment variables
|
||||||
|
client := NewClient()
|
||||||
|
|
||||||
|
// Test with a real email address (consider using a test address)
|
||||||
|
recipients := []string{"romaric.sirii@gmail.com"}
|
||||||
|
subject := "Test Email from Go Test"
|
||||||
|
body := "This is a test email sent from the mail package test"
|
||||||
|
|
||||||
|
// Send the email
|
||||||
|
err = client.SendMail(recipients, subject, body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("SendMail failed with error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
26
internal/server/server.go
Normal file
26
internal/server/server.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backea/internal/backup"
|
||||||
|
"backea/internal/web/routes"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// New creates a new configured Echo server
|
||||||
|
func New(factory *backup.BackupFactory) *echo.Echo {
|
||||||
|
e := echo.New()
|
||||||
|
|
||||||
|
// Add middleware
|
||||||
|
e.Use(middleware.Logger())
|
||||||
|
e.Use(middleware.Recover())
|
||||||
|
|
||||||
|
// Setup static file serving
|
||||||
|
e.Static("/static", "static")
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
routes.RegisterRoutes(e, factory)
|
||||||
|
|
||||||
|
return e
|
||||||
|
}
|
377
internal/web/handlers/backup_actions_handler.go
Normal file
377
internal/web/handlers/backup_actions_handler.go
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backea/internal/backup"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackupActionsHandler handles backup-related actions like restore and download
|
||||||
|
type BackupActionsHandler struct {
|
||||||
|
backupFactory *backup.BackupFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBackupActionsHandler creates a new backup actions handler
|
||||||
|
func NewBackupActionsHandler(factory *backup.BackupFactory) *BackupActionsHandler {
|
||||||
|
return &BackupActionsHandler{
|
||||||
|
backupFactory: factory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreRequest represents the request data for a backup restore
|
||||||
|
type RestoreRequest struct {
|
||||||
|
BackupID string `json:"backupID" form:"backupID"`
|
||||||
|
GroupName string `json:"groupName" form:"groupName"`
|
||||||
|
ServiceIndex string `json:"serviceIndex" form:"serviceIndex"`
|
||||||
|
DestinationPath string `json:"destinationPath" form:"destinationPath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreBackup handles the request to restore a backup with destination path support
|
||||||
|
func (h *BackupActionsHandler) RestoreBackup(c echo.Context) error {
|
||||||
|
backupID := c.Param("backupID")
|
||||||
|
if backupID == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "backupID is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse the group and service from query parameters
|
||||||
|
groupName := c.QueryParam("groupName")
|
||||||
|
serviceIndex := c.QueryParam("serviceIndex")
|
||||||
|
destinationPath := c.QueryParam("destinationPath")
|
||||||
|
|
||||||
|
// If not in query parameters, check form data
|
||||||
|
if groupName == "" || serviceIndex == "" {
|
||||||
|
var req RestoreRequest
|
||||||
|
if err := c.Bind(&req); err == nil {
|
||||||
|
groupName = req.GroupName
|
||||||
|
serviceIndex = req.ServiceIndex
|
||||||
|
destinationPath = req.DestinationPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if groupName == "" || serviceIndex == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest,
|
||||||
|
"groupName and serviceIndex are required either as query parameters or in the request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct full service name
|
||||||
|
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
|
||||||
|
|
||||||
|
log.Printf("Restoring backup ID=%s to service %s", backupID, serviceName)
|
||||||
|
if destinationPath != "" {
|
||||||
|
log.Printf("Restoring to custom destination: %s", destinationPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the appropriate strategy for this service
|
||||||
|
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
|
fmt.Sprintf("Failed to get backup strategy: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If destination path is provided, use our custom restore function
|
||||||
|
if destinationPath != "" {
|
||||||
|
err = h.restoreBackupToCustomDestination(c.Request().Context(), strategy, backupID, serviceName, destinationPath)
|
||||||
|
} else {
|
||||||
|
// Otherwise use the regular restore
|
||||||
|
err = strategy.RestoreBackup(c.Request().Context(), backupID, serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
|
fmt.Sprintf("Failed to restore backup: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{
|
||||||
|
"status": "success",
|
||||||
|
"message": fmt.Sprintf("Backup %s restored successfully", backupID),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreBackupForm shows the restore form with directory input
|
||||||
|
func (h *BackupActionsHandler) RestoreBackupForm(c echo.Context) error {
|
||||||
|
backupID := c.QueryParam("backupID")
|
||||||
|
groupName := c.QueryParam("groupName")
|
||||||
|
serviceIndex := c.QueryParam("serviceIndex")
|
||||||
|
|
||||||
|
if backupID == "" || groupName == "" || serviceIndex == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "backupID, groupName, and serviceIndex are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build service name for lookup
|
||||||
|
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
|
||||||
|
|
||||||
|
// Get backup info if available
|
||||||
|
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
|
fmt.Sprintf("Failed to get backup strategy: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get backup info
|
||||||
|
backupInfo, err := strategy.GetBackupInfo(c.Request().Context(), backupID, serviceName)
|
||||||
|
var backupTimeStr string
|
||||||
|
var backupSizeStr string
|
||||||
|
var defaultPath string
|
||||||
|
|
||||||
|
if err == nil && backupInfo != nil {
|
||||||
|
backupTimeStr = backupInfo.CreationTime.Format("Jan 02, 2006 15:04:05")
|
||||||
|
backupSizeStr = formatSize(backupInfo.Size)
|
||||||
|
defaultPath = backupInfo.Source // Use original source as default path
|
||||||
|
} else {
|
||||||
|
backupTimeStr = "Unknown"
|
||||||
|
backupSizeStr = "Unknown"
|
||||||
|
defaultPath = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO : create a real template
|
||||||
|
return c.HTML(http.StatusOK, fmt.Sprintf(`
|
||||||
|
<div class="restore-modal gruvbox-bg1 round padding">
|
||||||
|
<h3 class="gruvbox-yellow">Restore Backup</h3>
|
||||||
|
<div class="backup-details">
|
||||||
|
<p><span class="gruvbox-fg-dim">Backup Date:</span> <span class="gruvbox-yellow">%s</span></p>
|
||||||
|
<p><span class="gruvbox-fg-dim">Size:</span> <span class="gruvbox-yellow">%s</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
id="restore-form"
|
||||||
|
hx-post="/api/backups/%s/restore"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-indicator="#restore-indicator"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="backupID" value="%s">
|
||||||
|
<input type="hidden" name="groupName" value="%s">
|
||||||
|
<input type="hidden" name="serviceIndex" value="%s">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="destinationPath" class="gruvbox-fg-dim">Destination Path:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="destinationPath"
|
||||||
|
name="destinationPath"
|
||||||
|
class="gruvbox-input"
|
||||||
|
value="%s"
|
||||||
|
placeholder="Leave empty to restore to original location"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group form-buttons">
|
||||||
|
<button type="submit" class="gruvbox-button gruvbox-bg2">
|
||||||
|
Restore
|
||||||
|
<span id="restore-indicator" class="htmx-indicator">
|
||||||
|
<span class="loading-spinner inline-spinner"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="gruvbox-button gruvbox-bg3"
|
||||||
|
onclick="document.querySelector('.restore-modal').remove()"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`, backupTimeStr, backupSizeStr, backupID, backupID, groupName, serviceIndex, defaultPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreBackupToCustomDestination handles restoring a backup to a custom directory
|
||||||
|
func (h *BackupActionsHandler) restoreBackupToCustomDestination(ctx context.Context, strategy backup.Strategy, backupID, serviceName, destinationPath string) error {
|
||||||
|
// Validate destination path
|
||||||
|
destinationPath = filepath.Clean(destinationPath)
|
||||||
|
|
||||||
|
// Check if destination exists
|
||||||
|
stat, err := os.Stat(destinationPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(destinationPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("failed to check destination directory: %w", err)
|
||||||
|
}
|
||||||
|
} else if !stat.IsDir() {
|
||||||
|
return fmt.Errorf("destination path is not a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary directory to store the downloaded backup
|
||||||
|
tempDir, err := os.MkdirTemp("", "backea-restore-download-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temporary download directory: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tempDir)
|
||||||
|
|
||||||
|
// Create a temporary directory for extraction
|
||||||
|
extractDir, err := os.MkdirTemp("", "backea-restore-extract-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temporary extraction directory: %w", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(extractDir)
|
||||||
|
|
||||||
|
// Download the backup to a temporary file
|
||||||
|
log.Printf("Downloading backup %s to temporary location for service %s", backupID, serviceName)
|
||||||
|
reader, err := strategy.DownloadBackup(ctx, backupID, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download backup: %w", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Create a temporary file to store the zip
|
||||||
|
zipFile, err := os.CreateTemp(tempDir, "backup-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temporary file: %w", err)
|
||||||
|
}
|
||||||
|
zipPath := zipFile.Name()
|
||||||
|
defer os.Remove(zipPath)
|
||||||
|
|
||||||
|
// Copy the zip content to the temp file
|
||||||
|
log.Printf("Writing backup data to temporary file: %s", zipPath)
|
||||||
|
_, err = io.Copy(zipFile, reader)
|
||||||
|
if err != nil {
|
||||||
|
zipFile.Close()
|
||||||
|
return fmt.Errorf("failed to write backup to disk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush to disk and close the file
|
||||||
|
zipFile.Sync()
|
||||||
|
zipFile.Close()
|
||||||
|
|
||||||
|
// Extract the zip to the extraction directory
|
||||||
|
log.Printf("Extracting backup to %s", extractDir)
|
||||||
|
if err := extractZip(zipPath, extractDir); err != nil {
|
||||||
|
return fmt.Errorf("failed to extract backup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync extracted files to the destination using rsync
|
||||||
|
log.Printf("Syncing files from %s to %s", extractDir, destinationPath)
|
||||||
|
if err := syncDirectories(extractDir, destinationPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to sync files to destination: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Backup successfully restored to %s", destinationPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractZip extracts a zip file to a destination directory
|
||||||
|
func extractZip(zipFile, destDir string) error {
|
||||||
|
// Use unzip command for extraction
|
||||||
|
cmd := exec.Command("unzip", "-o", "-q", zipFile, "-d", destDir)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to extract zip: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncDirectories syncs files from source to destination using rsync
|
||||||
|
func syncDirectories(src, dest string) error {
|
||||||
|
// Ensure source path ends with a slash to copy contents, not the directory itself
|
||||||
|
if !strings.HasSuffix(src, "/") {
|
||||||
|
src = src + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("rsync", "-av", "--delete", src, dest)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to sync directories: %w, output: %s", err, string(output))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format file size
|
||||||
|
func formatSize(size int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if size < unit {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := size / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f %cB", float64(size)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadBackup handles the backup download request
|
||||||
|
func (h *BackupActionsHandler) DownloadBackup(c echo.Context) error {
|
||||||
|
// Get query parameters
|
||||||
|
backupID := c.QueryParam("backupID")
|
||||||
|
groupName := c.QueryParam("groupName")
|
||||||
|
serviceIndex := c.QueryParam("serviceIndex")
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if backupID == "" || groupName == "" || serviceIndex == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "backupID, groupName, and serviceIndex are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create service name for logging
|
||||||
|
serviceName := fmt.Sprintf("%s.%s", groupName, serviceIndex)
|
||||||
|
log.Printf("Downloading backup: ID=%s, Service=%s", backupID, serviceName)
|
||||||
|
|
||||||
|
// Get the appropriate strategy for this service
|
||||||
|
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, serviceIndex)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
|
fmt.Sprintf("Failed to get backup strategy: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all backups for this service
|
||||||
|
backups, err := strategy.ListBackups(c.Request().Context(), serviceName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: Failed to list backups: %v. Will try with provided ID directly.", err)
|
||||||
|
} else {
|
||||||
|
// Look for a matching backup with the proper snapshot ID
|
||||||
|
found := false
|
||||||
|
for _, backup := range backups {
|
||||||
|
// If we find an exact match, or if the backup ID contains our ID (partial match)
|
||||||
|
if backup.ID == backupID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
log.Printf("Warning: Backup ID %s not found in service backups. Will try direct download anyway.", backupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to download the backup using the provided ID
|
||||||
|
backupReader, err := strategy.DownloadBackup(c.Request().Context(), backupID, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||||
|
fmt.Sprintf("Failed to download backup: %v", err))
|
||||||
|
}
|
||||||
|
defer backupReader.Close()
|
||||||
|
|
||||||
|
// Set filename for download
|
||||||
|
filename := fmt.Sprintf("%s_%s_%s.zip",
|
||||||
|
groupName,
|
||||||
|
serviceIndex,
|
||||||
|
time.Now().Format("20060102_150405"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set response headers for zip download
|
||||||
|
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||||
|
c.Response().Header().Set("Content-Type", "application/zip")
|
||||||
|
|
||||||
|
// Stream the file to the client
|
||||||
|
log.Printf("Streaming backup as %s", filename)
|
||||||
|
_, err = io.Copy(c.Response().Writer, backupReader)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error streaming backup: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Backup download for %s completed successfully", backupID)
|
||||||
|
return nil
|
||||||
|
}
|
317
internal/web/handlers/homepage_handler.go
Normal file
317
internal/web/handlers/homepage_handler.go
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backea/internal/backup"
|
||||||
|
"backea/templates"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HomepageHandler struct {
|
||||||
|
backupFactory *backup.BackupFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHomepageHandler(factory *backup.BackupFactory) *HomepageHandler {
|
||||||
|
return &HomepageHandler{
|
||||||
|
backupFactory: factory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home handles the homepage request and displays latest backups by service
|
||||||
|
func (h *HomepageHandler) Home(c echo.Context) error {
|
||||||
|
// Create the data structures
|
||||||
|
serviceBackups := make(map[string]map[string][]backup.BackupInfo)
|
||||||
|
serviceConfigs := make(map[string]map[string]templates.ServiceProviderInfo)
|
||||||
|
groupDirectories := make(map[string]string) // Store directories by group name
|
||||||
|
|
||||||
|
// Process each service group
|
||||||
|
for groupName, serviceGroup := range h.backupFactory.Config.Services {
|
||||||
|
// Initialize maps for this group
|
||||||
|
serviceBackups[groupName] = make(map[string][]backup.BackupInfo)
|
||||||
|
serviceConfigs[groupName] = make(map[string]templates.ServiceProviderInfo)
|
||||||
|
|
||||||
|
// Store the directory at the group level in a separate map
|
||||||
|
groupDirectories[groupName] = serviceGroup.Source.Path
|
||||||
|
|
||||||
|
// Process each backup config in the group
|
||||||
|
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
|
||||||
|
// Store service configuration
|
||||||
|
serviceConfigs[groupName][configIndex] = templates.ServiceProviderInfo{
|
||||||
|
Type: backupConfig.BackupStrategy.Type,
|
||||||
|
Provider: backupConfig.BackupStrategy.Provider,
|
||||||
|
Directory: serviceGroup.Source.Path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get sorted group names for alphabetical ordering
|
||||||
|
var sortedGroupNames []string
|
||||||
|
for groupName := range serviceBackups {
|
||||||
|
sortedGroupNames = append(sortedGroupNames, groupName)
|
||||||
|
}
|
||||||
|
sort.Strings(sortedGroupNames)
|
||||||
|
|
||||||
|
// Render the template with sorted group names and directories
|
||||||
|
// We don't load actual backup data on initial page load, it will be loaded via HTMX
|
||||||
|
component := templates.Home(serviceBackups, serviceConfigs, sortedGroupNames, groupDirectories)
|
||||||
|
return component.Render(c.Request().Context(), c.Response().Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceGroupHeader returns the header section for a specific service group (HTMX endpoint)
|
||||||
|
func (h *HomepageHandler) ServiceGroupHeader(c echo.Context) error {
|
||||||
|
groupName := c.Param("groupName")
|
||||||
|
|
||||||
|
// Check if the service group exists
|
||||||
|
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
|
||||||
|
if !exists {
|
||||||
|
return echo.NewHTTPError(404, "Service group not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create data structures
|
||||||
|
serviceBackups := make(map[string][]backup.BackupInfo)
|
||||||
|
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
|
||||||
|
|
||||||
|
// Setup synchronization
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
// Process each backup config in the group
|
||||||
|
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
|
||||||
|
// Store service configuration
|
||||||
|
serviceConfigs[configIndex] = templates.ServiceProviderInfo{
|
||||||
|
Type: backupConfig.BackupStrategy.Type,
|
||||||
|
Provider: backupConfig.BackupStrategy.Provider,
|
||||||
|
Directory: serviceGroup.Source.Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch backups in parallel
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Get backup strategy
|
||||||
|
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, index)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating strategy for %s.%s: %v", groupName, index, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backups
|
||||||
|
backups, err := strategy.ListBackups(context.Background(), groupName+"."+index)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error listing backups for %s.%s: %v", groupName, index, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort backups by time (newest first)
|
||||||
|
sort.Slice(backups, func(i, j int) bool {
|
||||||
|
return backups[i].CreationTime.After(backups[j].CreationTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store result
|
||||||
|
mu.Lock()
|
||||||
|
serviceBackups[index] = backups
|
||||||
|
mu.Unlock()
|
||||||
|
}(configIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all goroutines to finish
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Render just the header component
|
||||||
|
component := templates.GroupHeaderComponent(
|
||||||
|
groupName,
|
||||||
|
serviceBackups,
|
||||||
|
serviceConfigs,
|
||||||
|
serviceGroup.Source.Path,
|
||||||
|
)
|
||||||
|
return component.Render(c.Request().Context(), c.Response().Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceGroupBackups returns just the backups table for a specific service group (HTMX endpoint)
|
||||||
|
func (h *HomepageHandler) ServiceGroupBackups(c echo.Context) error {
|
||||||
|
groupName := c.Param("groupName")
|
||||||
|
|
||||||
|
// Check if the service group exists
|
||||||
|
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
|
||||||
|
if !exists {
|
||||||
|
return echo.NewHTTPError(404, "Service group not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create data structures
|
||||||
|
serviceBackups := make(map[string][]backup.BackupInfo)
|
||||||
|
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
|
||||||
|
|
||||||
|
// Setup synchronization
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
// Process each backup config in the group
|
||||||
|
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
|
||||||
|
// Store service configuration
|
||||||
|
serviceConfigs[configIndex] = templates.ServiceProviderInfo{
|
||||||
|
Type: backupConfig.BackupStrategy.Type,
|
||||||
|
Provider: backupConfig.BackupStrategy.Provider,
|
||||||
|
Directory: serviceGroup.Source.Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch backups in parallel
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Get backup strategy
|
||||||
|
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, index)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating strategy for %s.%s: %v", groupName, index, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backups
|
||||||
|
backups, err := strategy.ListBackups(context.Background(), groupName+"."+index)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error listing backups for %s.%s: %v", groupName, index, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort backups by time (newest first)
|
||||||
|
sort.Slice(backups, func(i, j int) bool {
|
||||||
|
return backups[i].CreationTime.After(backups[j].CreationTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store result
|
||||||
|
mu.Lock()
|
||||||
|
serviceBackups[index] = backups
|
||||||
|
mu.Unlock()
|
||||||
|
}(configIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all goroutines to finish
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Create a map with just the group for the template
|
||||||
|
groupServiceBackups := make(map[string]map[string][]backup.BackupInfo)
|
||||||
|
groupServiceBackups[groupName] = serviceBackups
|
||||||
|
|
||||||
|
// Create a map with just the group configs for the template
|
||||||
|
groupServiceConfigs := make(map[string]map[string]templates.ServiceProviderInfo)
|
||||||
|
groupServiceConfigs[groupName] = serviceConfigs
|
||||||
|
|
||||||
|
// Render only the backups table component
|
||||||
|
component := templates.ServiceGroupBackupsTable(groupName, serviceBackups, groupServiceConfigs)
|
||||||
|
return component.Render(c.Request().Context(), c.Response().Writer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceGroupAllBackups returns all backups for a specific service group (HTMX endpoint)
|
||||||
|
func (h *HomepageHandler) ServiceGroupAllBackups(c echo.Context) error {
|
||||||
|
groupName := c.Param("groupName")
|
||||||
|
|
||||||
|
// Check if the service group exists
|
||||||
|
serviceGroup, exists := h.backupFactory.Config.Services[groupName]
|
||||||
|
if !exists {
|
||||||
|
return echo.NewHTTPError(404, "Service group not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create data structures
|
||||||
|
serviceBackups := make(map[string][]backup.BackupInfo)
|
||||||
|
serviceConfigs := make(map[string]templates.ServiceProviderInfo)
|
||||||
|
|
||||||
|
// Setup synchronization
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
// Process each backup config in the group
|
||||||
|
for configIndex, backupConfig := range serviceGroup.BackupConfigs {
|
||||||
|
// Store service configuration
|
||||||
|
serviceConfigs[configIndex] = templates.ServiceProviderInfo{
|
||||||
|
Type: backupConfig.BackupStrategy.Type,
|
||||||
|
Provider: backupConfig.BackupStrategy.Provider,
|
||||||
|
Directory: serviceGroup.Source.Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch backups in parallel
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index string) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Get backup strategy
|
||||||
|
strategy, err := h.backupFactory.CreateBackupStrategyForService(groupName, index)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating strategy for %s.%s: %v", groupName, index, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get backups
|
||||||
|
backups, err := strategy.ListBackups(context.Background(), groupName+"."+index)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error listing backups for %s.%s: %v", groupName, index, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort backups by time (newest first)
|
||||||
|
sort.Slice(backups, func(i, j int) bool {
|
||||||
|
return backups[i].CreationTime.After(backups[j].CreationTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store result
|
||||||
|
mu.Lock()
|
||||||
|
serviceBackups[index] = backups
|
||||||
|
mu.Unlock()
|
||||||
|
}(configIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all goroutines to finish
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Create a map with just the group for the template
|
||||||
|
groupServiceBackups := make(map[string]map[string][]backup.BackupInfo)
|
||||||
|
groupServiceBackups[groupName] = serviceBackups
|
||||||
|
|
||||||
|
// Create a map with just the group configs for the template
|
||||||
|
groupServiceConfigs := make(map[string]map[string]templates.ServiceProviderInfo)
|
||||||
|
groupServiceConfigs[groupName] = serviceConfigs
|
||||||
|
|
||||||
|
// Also trigger a header update to refresh stats
|
||||||
|
go func() {
|
||||||
|
// Create a new context since the original one might be cancelled
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Sleep briefly to ensure the table loads first
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Make an HTTP request to refresh the header
|
||||||
|
url := fmt.Sprintf("http://localhost:%s/api/service-group/%s/header",
|
||||||
|
os.Getenv("PORT"),
|
||||||
|
groupName,
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating request to refresh header: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set HTMX headers to target the correct element
|
||||||
|
req.Header.Set("HX-Request", "true")
|
||||||
|
req.Header.Set("HX-Target", fmt.Sprintf("group-header-%s", groupName))
|
||||||
|
|
||||||
|
// Make the request
|
||||||
|
client := &http.Client{}
|
||||||
|
_, err = client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error refreshing header: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Render the all backups table component
|
||||||
|
component := templates.ServiceGroupAllBackupsTable(groupName, serviceBackups, groupServiceConfigs)
|
||||||
|
return component.Render(c.Request().Context(), c.Response().Writer)
|
||||||
|
}
|
34
internal/web/routes/routes.go
Normal file
34
internal/web/routes/routes.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backea/internal/backup"
|
||||||
|
"backea/internal/web/handlers"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterRoutes sets up all the routes for the web application
|
||||||
|
func RegisterRoutes(e *echo.Echo, factory *backup.BackupFactory) {
|
||||||
|
// Create handlers with backup factory
|
||||||
|
homeHandler := handlers.NewHomepageHandler(factory)
|
||||||
|
actionsHandler := handlers.NewBackupActionsHandler(factory)
|
||||||
|
|
||||||
|
// Register main routes
|
||||||
|
e.GET("/", homeHandler.Home)
|
||||||
|
|
||||||
|
// Register HTMX API endpoints for lazy loading
|
||||||
|
e.GET("/api/service-group/:groupName/header", homeHandler.ServiceGroupHeader)
|
||||||
|
e.GET("/api/service-group/:groupName/backups", homeHandler.ServiceGroupBackups)
|
||||||
|
e.GET("/api/service-group/:groupName/all-backups", homeHandler.ServiceGroupAllBackups)
|
||||||
|
|
||||||
|
// Register backup action routes - using named parameter for backupID
|
||||||
|
e.POST("/api/backups/:backupID/restore", actionsHandler.RestoreBackup)
|
||||||
|
e.GET("/api/backups/restore-form", actionsHandler.RestoreBackupForm)
|
||||||
|
e.GET("/api/backups/download", actionsHandler.DownloadBackup)
|
||||||
|
|
||||||
|
// Add this after routes registration but before server start to see all routes
|
||||||
|
for _, route := range e.Routes() {
|
||||||
|
fmt.Printf("Method: %s, Path: %s, Handler: %s\n", route.Method, route.Path, route.Name)
|
||||||
|
}
|
||||||
|
}
|
505
templates/home.templ
Normal file
505
templates/home.templ
Normal file
@ -0,0 +1,505 @@
|
|||||||
|
package templates
|
||||||
|
import (
|
||||||
|
"backea/internal/backup"
|
||||||
|
"backea/templates/layouts"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatSize formats byte size to human-readable format
|
||||||
|
func FormatSize(size int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if size < unit {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := size / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.2f %cB", float64(size)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTime formats time to a readable format
|
||||||
|
func FormatTime(t time.Time) string {
|
||||||
|
return t.Format("Jan 02, 2006 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTimeSince formats the duration since a time in a human-readable way
|
||||||
|
func FormatTimeSince(t time.Time) string {
|
||||||
|
timeSince := time.Since(t)
|
||||||
|
hours := int(timeSince.Hours())
|
||||||
|
|
||||||
|
if hours < 1 {
|
||||||
|
return fmt.Sprintf("%d minutes ago", int(timeSince.Minutes()))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hours ago", hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatusClass returns the appropriate status class based on backup age
|
||||||
|
func GetStatusClass(t time.Time) string {
|
||||||
|
timeSince := time.Since(t)
|
||||||
|
hours := int(timeSince.Hours())
|
||||||
|
|
||||||
|
if hours > 72 {
|
||||||
|
return "Failed"
|
||||||
|
} else if hours > 24 {
|
||||||
|
return "Warning"
|
||||||
|
}
|
||||||
|
return "Healthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatServiceName returns a display-friendly service name
|
||||||
|
func FormatServiceName(groupName, serviceIndex string) string {
|
||||||
|
if serviceIndex == "" {
|
||||||
|
return groupName
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s - %s", groupName, serviceIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateTotalSize calculates total size of all backups
|
||||||
|
func CalculateTotalSize(backups []backup.BackupInfo) int64 {
|
||||||
|
var total int64
|
||||||
|
for _, b := range backups {
|
||||||
|
total += b.Size
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateGroupTotalSize calculates total size of all backups for a service group
|
||||||
|
func CalculateGroupTotalSize(serviceGroup map[string][]backup.BackupInfo) int64 {
|
||||||
|
var total int64
|
||||||
|
for _, backups := range serviceGroup {
|
||||||
|
for _, b := range backups {
|
||||||
|
total += b.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupTotalBackupCount returns the total number of backups across all services in a group
|
||||||
|
func GetGroupTotalBackupCount(serviceGroup map[string][]backup.BackupInfo) int {
|
||||||
|
count := 0
|
||||||
|
for _, backups := range serviceGroup {
|
||||||
|
count += len(backups)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestBackupTime returns the most recent backup time for a service group
|
||||||
|
func GetLatestBackupTime(serviceGroup map[string][]backup.BackupInfo) (time.Time, bool) {
|
||||||
|
var latestTime time.Time
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, backups := range serviceGroup {
|
||||||
|
if len(backups) > 0 && (latestTime.IsZero() || backups[0].CreationTime.After(latestTime)) {
|
||||||
|
latestTime = backups[0].CreationTime
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latestTime, found
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupStatus returns the status of a service group based on the most recent backup
|
||||||
|
func GetGroupStatus(serviceGroup map[string][]backup.BackupInfo) string {
|
||||||
|
latestTime, found := GetLatestBackupTime(serviceGroup)
|
||||||
|
if !found {
|
||||||
|
return "No Backups"
|
||||||
|
}
|
||||||
|
return GetStatusClass(latestTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceProviderInfo holds the backup strategy info for a service
|
||||||
|
type ServiceProviderInfo struct {
|
||||||
|
Type string
|
||||||
|
Provider string
|
||||||
|
Directory string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupWithService represents a backup with its service identifier
|
||||||
|
type BackupWithService struct {
|
||||||
|
ServiceIndex string
|
||||||
|
Backup backup.BackupInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSortedBackups collects all backups from a service group and sorts them by time
|
||||||
|
func GetSortedBackups(serviceGroup map[string][]backup.BackupInfo) []BackupWithService {
|
||||||
|
var allBackups []BackupWithService
|
||||||
|
|
||||||
|
// Collect all backups with their service indices
|
||||||
|
for serviceIndex, backups := range serviceGroup {
|
||||||
|
for _, b := range backups {
|
||||||
|
allBackups = append(allBackups, BackupWithService{
|
||||||
|
ServiceIndex: serviceIndex,
|
||||||
|
Backup: b,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by creation time (newest first)
|
||||||
|
sort.Slice(allBackups, func(i, j int) bool {
|
||||||
|
return allBackups[i].Backup.CreationTime.After(allBackups[j].Backup.CreationTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
return allBackups
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home renders the homepage with lazy-loaded backup information
|
||||||
|
templ Home(serviceBackups map[string]map[string][]backup.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo, sortedGroupNames []string, groupDirectories map[string]string) {
|
||||||
|
@layouts.Base("Backea - Backup Dashboard") {
|
||||||
|
<div class="responsive gruvbox-dark">
|
||||||
|
<div class="gruvbox-bg-hard round padding margin-bottom">
|
||||||
|
<h1 class="extra-large medium gruvbox-aqua">Welcome to Backea</h1>
|
||||||
|
<p class="large gruvbox-blue">Unified guardians.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="large medium margin-bottom gruvbox-orange">Latest Backups by Service</h2>
|
||||||
|
|
||||||
|
<!-- Check if we have any backups -->
|
||||||
|
if len(serviceBackups) == 0 {
|
||||||
|
<div id="no-backups" class="gruvbox-bg1 gruvbox-yellow-border round border padding">
|
||||||
|
<p class="gruvbox-yellow">No backup services configured or no backups found.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div id="has-backups">
|
||||||
|
<!-- Loop through groups in alphabetical order -->
|
||||||
|
for _, groupName := range sortedGroupNames {
|
||||||
|
<div class="margin-bottom-large service-group-container" id={ fmt.Sprintf("group-%s", groupName) }>
|
||||||
|
<!-- Header section with loading skeleton - will be replaced with actual header -->
|
||||||
|
<div id={ fmt.Sprintf("group-header-%s", groupName) }
|
||||||
|
hx-get={ fmt.Sprintf("/api/service-group/%s/header", groupName) }
|
||||||
|
hx-trigger="load once"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
<div class="gruvbox-bg1 round padding margin-bottom">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h3 class="medium">
|
||||||
|
<!-- Display directory from groupDirectories map -->
|
||||||
|
if directory, exists := groupDirectories[groupName]; exists && directory != "" {
|
||||||
|
<span class="gruvbox-orange">{ directory }</span>
|
||||||
|
} else {
|
||||||
|
<span class="gruvbox-green">Unknown Directory</span>
|
||||||
|
}
|
||||||
|
<span class="gruvbox-green">{ groupName }</span>
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<!-- Display backup type and provider based on first service in group -->
|
||||||
|
if len(serviceBackups[groupName]) > 0 {
|
||||||
|
for serviceIndex := range serviceBackups[groupName] {
|
||||||
|
if providerInfo, exists := serviceConfigs[groupName][serviceIndex]; exists {
|
||||||
|
<span class="backup-tool-tag gruvbox-blue">{ providerInfo.Type }</span>
|
||||||
|
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
|
||||||
|
<span class="backup-tool-tag gruvbox-purple">B2 Backblaze</span>
|
||||||
|
} else if providerInfo.Provider == "ftp" {
|
||||||
|
<span class="backup-tool-tag gruvbox-yellow">FTP</span>
|
||||||
|
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
|
||||||
|
<span class="backup-tool-tag gruvbox-orange">SSH</span>
|
||||||
|
} else if providerInfo.Provider == "s3" {
|
||||||
|
<span class="backup-tool-tag gruvbox-red">S3</span>
|
||||||
|
} else {
|
||||||
|
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Provider }</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
<span class="backup-tool-tag gruvbox-blue">Unknown</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Summary stats with skeleton loading -->
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.5rem;">
|
||||||
|
<div>
|
||||||
|
<p class="gruvbox-fg-dim">Total Size</p>
|
||||||
|
<p class="gruvbox-yellow skeleton-text">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="gruvbox-fg-dim">Backups</p>
|
||||||
|
<p class="gruvbox-yellow skeleton-text">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="gruvbox-fg-dim">Last Backup</p>
|
||||||
|
<p class="gruvbox-yellow skeleton-text">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="gruvbox-fg-dim">Status</p>
|
||||||
|
<p class="gruvbox-yellow skeleton-text">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Always render the lazy-loading skeleton for backup table -->
|
||||||
|
<div class="table-skeleton"
|
||||||
|
id={ fmt.Sprintf("skeleton-%s", groupName) }
|
||||||
|
hx-get={ fmt.Sprintf("/api/service-group/%s/backups", groupName) }
|
||||||
|
hx-trigger="load once"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-indicator={ fmt.Sprintf("#loading-indicator-%s", groupName) }>
|
||||||
|
<div class="skeleton-table gruvbox-bg1 round padding">
|
||||||
|
<div class="skeleton-header">
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-row">
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-row">
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
<div class="skeleton-cell"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id={ fmt.Sprintf("loading-indicator-%s", groupName) } class="htmx-indicator">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupHeaderComponent renders just the group header with up-to-date stats
|
||||||
|
templ GroupHeaderComponent(groupName string, serviceBackups map[string][]backup.BackupInfo, serviceConfigs map[string]ServiceProviderInfo, directory string) {
|
||||||
|
<div id={ fmt.Sprintf("group-header-%s", groupName) } class="gruvbox-bg1 round padding margin-bottom">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h3 class="medium">
|
||||||
|
if directory != "" {
|
||||||
|
<span class="gruvbox-orange">{ directory }</span>
|
||||||
|
} else {
|
||||||
|
<span class="gruvbox-green">Unknown Directory</span>
|
||||||
|
}
|
||||||
|
<span class="gruvbox-green">{ groupName }</span>
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<!-- Display backup type and provider based on first service in group -->
|
||||||
|
if len(serviceBackups) > 0 {
|
||||||
|
for serviceIndex := range serviceBackups {
|
||||||
|
if providerInfo, exists := serviceConfigs[serviceIndex]; exists {
|
||||||
|
<span class="backup-tool-tag gruvbox-blue">{ providerInfo.Type }</span>
|
||||||
|
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
|
||||||
|
<span class="backup-tool-tag gruvbox-purple">B2 Backblaze</span>
|
||||||
|
} else if providerInfo.Provider == "ftp" {
|
||||||
|
<span class="backup-tool-tag gruvbox-yellow">FTP</span>
|
||||||
|
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
|
||||||
|
<span class="backup-tool-tag gruvbox-orange">SSH</span>
|
||||||
|
} else if providerInfo.Provider == "s3" {
|
||||||
|
<span class="backup-tool-tag gruvbox-red">S3</span>
|
||||||
|
} else {
|
||||||
|
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Provider }</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
<span class="backup-tool-tag gruvbox-blue">Unknown</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Summary stats with actual data -->
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.5rem;">
|
||||||
|
<div>
|
||||||
|
<p class="gruvbox-fg-dim">Total Size</p>
|
||||||
|
<p class="gruvbox-yellow">{ FormatSize(CalculateGroupTotalSize(serviceBackups)) }</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="gruvbox-fg-dim">Backups</p>
|
||||||
|
<p class="gruvbox-yellow">{ fmt.Sprintf("%d", GetGroupTotalBackupCount(serviceBackups)) }</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="gruvbox-fg-dim">Last Backup</p>
|
||||||
|
if latestTime, found := GetLatestBackupTime(serviceBackups); found {
|
||||||
|
<p class="gruvbox-yellow">{ FormatTimeSince(latestTime) }</p>
|
||||||
|
} else {
|
||||||
|
<p class="gruvbox-yellow">Never</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="gruvbox-fg-dim">Status</p>
|
||||||
|
if status := GetGroupStatus(serviceBackups); status == "No Backups" {
|
||||||
|
<p class="gruvbox-red">No Backups</p>
|
||||||
|
} else if status == "Failed" {
|
||||||
|
<p class="gruvbox-red">Failed</p>
|
||||||
|
} else if status == "Warning" {
|
||||||
|
<p class="gruvbox-yellow">Warning</p>
|
||||||
|
} else {
|
||||||
|
<p class="gruvbox-green">Healthy</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updated table templates with action column
|
||||||
|
templ ServiceGroupBackupsTable(groupName string, serviceGroup map[string][]backup.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
||||||
|
<div class="group-backups-table" id={ fmt.Sprintf("backups-%s", groupName) }>
|
||||||
|
if GetGroupTotalBackupCount(serviceGroup) > 0 {
|
||||||
|
<div class="overflow">
|
||||||
|
<table class="gruvbox-table">
|
||||||
|
<thead>
|
||||||
|
<tr class="gruvbox-bg1">
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Service</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Date</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Size</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Type</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Retention</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Location</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Get all backups from all services in this group, sorted by creation time -->
|
||||||
|
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), 5, serviceConfigs)
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- "View all" link - now using HTMX to replace the table content -->
|
||||||
|
if GetGroupTotalBackupCount(serviceGroup) > 5 {
|
||||||
|
<div class="margin-top right-align">
|
||||||
|
<button class="gruvbox-button gruvbox-bg2"
|
||||||
|
hx-get={ fmt.Sprintf("/api/service-group/%s/all-backups", groupName) }
|
||||||
|
hx-target={ fmt.Sprintf("#backups-%s", groupName) }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-indicator={ fmt.Sprintf("#view-all-indicator-%s", groupName) }>
|
||||||
|
View all { fmt.Sprintf("%d", GetGroupTotalBackupCount(serviceGroup)) } backups
|
||||||
|
<span id={ fmt.Sprintf("view-all-indicator-%s", groupName) } class="htmx-indicator">
|
||||||
|
<span class="loading-spinner inline-spinner"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="gruvbox-bg1 round padding">
|
||||||
|
<p class="gruvbox-yellow">No backups found for this service.</p>
|
||||||
|
<p class="gruvbox-fg-dim">Backups will appear here when created.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceGroupAllBackupsTable template for showing all backups
|
||||||
|
templ ServiceGroupAllBackupsTable(groupName string, serviceGroup map[string][]backup.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
||||||
|
<!-- We're returning just the INNER content now, not the whole container -->
|
||||||
|
if GetGroupTotalBackupCount(serviceGroup) > 0 {
|
||||||
|
<div class="overflow">
|
||||||
|
<table class="gruvbox-table">
|
||||||
|
<thead>
|
||||||
|
<tr class="gruvbox-bg1">
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Service</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Date</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Size</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Type</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Retention</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Location</th>
|
||||||
|
<th class="padding-small border-bottom left-align gruvbox-purple">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Show ALL backups, without limit -->
|
||||||
|
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), 9999, serviceConfigs)
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- "Show fewer" button that reloads original table -->
|
||||||
|
<div class="margin-top right-align">
|
||||||
|
<button class="gruvbox-button gruvbox-bg2"
|
||||||
|
hx-get={ fmt.Sprintf("/api/service-group/%s/backups", groupName) }
|
||||||
|
hx-target={ fmt.Sprintf("#backups-%s", groupName) }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-indicator={ fmt.Sprintf("#show-fewer-indicator-%s", groupName) }>
|
||||||
|
Show fewer backups
|
||||||
|
<span id={ fmt.Sprintf("show-fewer-indicator-%s", groupName) } class="htmx-indicator">
|
||||||
|
<span class="loading-spinner inline-spinner"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="gruvbox-bg1 round padding">
|
||||||
|
<p class="gruvbox-yellow">No backups found for this service.</p>
|
||||||
|
<p class="gruvbox-fg-dim">Backups will appear here when created.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Trigger header refresh with out-of-band swap -->
|
||||||
|
<div hx-get={ fmt.Sprintf("/api/service-group/%s/header", groupName) }
|
||||||
|
hx-trigger="load once"
|
||||||
|
hx-target={ fmt.Sprintf("#group-header-%s", groupName) }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
style="display:none;"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSortedBackups with action buttons
|
||||||
|
templ renderSortedBackups(groupName string, sortedBackups []BackupWithService, limit int, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
||||||
|
for i := 0; i < len(sortedBackups) && i < limit; i++ {
|
||||||
|
<tr class={ templ.Classes(
|
||||||
|
templ.KV("gruvbox-bg-hard", i % 2 == 0),
|
||||||
|
templ.KV("gruvbox-bg0", i % 2 != 0),
|
||||||
|
) }>
|
||||||
|
<td class="padding-small border-bottom gruvbox-fg">{ FormatServiceName(groupName, sortedBackups[i].ServiceIndex) }</td>
|
||||||
|
<td class="padding-small border-bottom gruvbox-fg">{ FormatTime(sortedBackups[i].Backup.CreationTime) }</td>
|
||||||
|
<td class="padding-small border-bottom gruvbox-fg">{ FormatSize(sortedBackups[i].Backup.Size) }</td>
|
||||||
|
<td class="padding-small border-bottom gruvbox-fg">{ sortedBackups[i].Backup.Type }</td>
|
||||||
|
<td class="padding-small border-bottom gruvbox-fg">{ sortedBackups[i].Backup.RetentionTag }</td>
|
||||||
|
<td class="padding-small border-bottom">
|
||||||
|
if providerInfo, exists := serviceConfigs[groupName][sortedBackups[i].ServiceIndex]; exists {
|
||||||
|
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
|
||||||
|
<span class="backup-tool-tag gruvbox-purple">{ providerInfo.Type } B2 Backblaze</span>
|
||||||
|
} else if providerInfo.Provider == "ftp" {
|
||||||
|
<span class="backup-tool-tag gruvbox-yellow">{ providerInfo.Type } FTP</span>
|
||||||
|
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
|
||||||
|
<span class="backup-tool-tag gruvbox-orange">{ providerInfo.Type } SSH</span>
|
||||||
|
} else if providerInfo.Provider == "s3" {
|
||||||
|
<span class="backup-tool-tag gruvbox-red">{ providerInfo.Type } S3</span>
|
||||||
|
} else {
|
||||||
|
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Type } { providerInfo.Provider }</span>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
<span class="backup-tool-tag gruvbox-gray">Unknown</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="padding-small border-bottom action-buttons">
|
||||||
|
<button class="action-button restore-button"
|
||||||
|
hx-get={ fmt.Sprintf("/api/backups/restore-form?backupID=%s&groupName=%s&serviceIndex=%s",
|
||||||
|
sortedBackups[i].Backup.ID,
|
||||||
|
groupName,
|
||||||
|
sortedBackups[i].ServiceIndex) }
|
||||||
|
hx-target="body"
|
||||||
|
hx-swap="beforeend">
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<a href={ templ.SafeURL(fmt.Sprintf("/api/backups/download?backupID=%s&groupName=%s&serviceIndex=%s",
|
||||||
|
sortedBackups[i].Backup.ID,
|
||||||
|
groupName,
|
||||||
|
sortedBackups[i].ServiceIndex)) }
|
||||||
|
class="action-button download-button">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// backupsTableRowsSorted renders backup rows sorted by creation time across all services
|
||||||
|
templ backupsTableRowsSorted(groupName string, serviceGroup map[string][]backup.BackupInfo, limit int, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
||||||
|
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), limit, serviceConfigs)
|
||||||
|
}
|
||||||
|
|
1231
templates/home_templ.go
Normal file
1231
templates/home_templ.go
Normal file
File diff suppressed because it is too large
Load Diff
632
templates/layouts/base.templ
Normal file
632
templates/layouts/base.templ
Normal file
@ -0,0 +1,632 @@
|
|||||||
|
package layouts
|
||||||
|
|
||||||
|
// Base provides the basic HTML structure for all pages
|
||||||
|
templ Base(title string) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>{ title }</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Storage meter styles */
|
||||||
|
.storage-meter {
|
||||||
|
height: 12px;
|
||||||
|
background-color: var(--gruvbox-bg2);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-meter.small {
|
||||||
|
height: 6px;
|
||||||
|
margin: 0.15rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-progress {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--gruvbox-blue);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon styles */
|
||||||
|
.storage-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon.error {
|
||||||
|
color: var(--gruvbox-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon.warning {
|
||||||
|
color: var(--gruvbox-yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon.success {
|
||||||
|
color: var(--gruvbox-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Backup tool tags */
|
||||||
|
.backup-tool-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart styles */
|
||||||
|
.donut-chart {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gruvbox Material Dark Theme for Backea */
|
||||||
|
:root {
|
||||||
|
/* Gruvbox Hard Dark Palette */
|
||||||
|
--gruvbox-bg-hard: #1d2021;
|
||||||
|
--gruvbox-bg0: #282828;
|
||||||
|
--gruvbox-bg1: #3c3836;
|
||||||
|
--gruvbox-bg2: #504945;
|
||||||
|
--gruvbox-bg3: #665c54;
|
||||||
|
--gruvbox-bg4: #7c6f64;
|
||||||
|
|
||||||
|
--gruvbox-fg: #fbf1c7;
|
||||||
|
--gruvbox-fg-dim: #d5c4a1;
|
||||||
|
|
||||||
|
--gruvbox-red: #fb4934;
|
||||||
|
--gruvbox-green: #b8bb26;
|
||||||
|
--gruvbox-yellow: #fabd2f;
|
||||||
|
--gruvbox-blue: #83a598;
|
||||||
|
--gruvbox-purple: #d3869b;
|
||||||
|
--gruvbox-aqua: #8ec07c;
|
||||||
|
--gruvbox-orange: #fe8019;
|
||||||
|
--gruvbox-gray: #928374;
|
||||||
|
}
|
||||||
|
/* Base dark mode styles */
|
||||||
|
.gruvbox-dark {
|
||||||
|
background-color: var(--gruvbox-bg-hard);
|
||||||
|
color: var(--gruvbox-fg);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
/* Background colors */
|
||||||
|
.gruvbox-bg-hard { background-color: var(--gruvbox-bg-hard); }
|
||||||
|
.gruvbox-bg0 { background-color: var(--gruvbox-bg0); }
|
||||||
|
.gruvbox-bg1 { background-color: var(--gruvbox-bg1); }
|
||||||
|
.gruvbox-bg2 { background-color: var(--gruvbox-bg2); }
|
||||||
|
/* Text colors */
|
||||||
|
.gruvbox-fg { color: var(--gruvbox-fg); }
|
||||||
|
.gruvbox-red { color: var(--gruvbox-red); }
|
||||||
|
.gruvbox-green { color: var(--gruvbox-green); }
|
||||||
|
.gruvbox-yellow { color: var(--gruvbox-yellow); }
|
||||||
|
.gruvbox-blue { color: var(--gruvbox-blue); }
|
||||||
|
.gruvbox-purple { color: var(--gruvbox-purple); }
|
||||||
|
.gruvbox-aqua { color: var(--gruvbox-aqua); }
|
||||||
|
.gruvbox-orange { color: var(--gruvbox-orange); }
|
||||||
|
.gruvbox-gray { color: var(--gruvbox-gray); }
|
||||||
|
/* Border colors */
|
||||||
|
.gruvbox-red-border { border-color: var(--gruvbox-red); }
|
||||||
|
.gruvbox-green-border { border-color: var(--gruvbox-green); }
|
||||||
|
.gruvbox-yellow-border { border-color: var(--gruvbox-yellow); }
|
||||||
|
.gruvbox-blue-border { border-color: var(--gruvbox-blue); }
|
||||||
|
.gruvbox-purple-border { border-color: var(--gruvbox-purple); }
|
||||||
|
.gruvbox-aqua-border { border-color: var(--gruvbox-aqua); }
|
||||||
|
/* Base dark mode styles */
|
||||||
|
html, body {
|
||||||
|
background-color: var(--gruvbox-bg-hard);
|
||||||
|
color: var(--gruvbox-fg);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* Table Styles */
|
||||||
|
.gruvbox-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-color: var(--gruvbox-bg3);
|
||||||
|
}
|
||||||
|
.gruvbox-table th {
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid var(--gruvbox-bg3);
|
||||||
|
}
|
||||||
|
.gruvbox-table tr {
|
||||||
|
border-bottom: 1px solid var(--gruvbox-bg2);
|
||||||
|
}
|
||||||
|
.gruvbox-table td, .gruvbox-table th {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
/* Link styles */
|
||||||
|
.gruvbox-link {
|
||||||
|
color: var(--gruvbox-blue);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
.gruvbox-link:hover {
|
||||||
|
color: var(--gruvbox-aqua);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
/* Warning container */
|
||||||
|
.gruvbox-warning {
|
||||||
|
background-color: var(--gruvbox-bg1);
|
||||||
|
border: 1px solid var(--gruvbox-yellow);
|
||||||
|
color: var(--gruvbox-yellow);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
/* Add Beer CSS compatibility for spacing and layout classes */
|
||||||
|
.responsive {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.round {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.padding {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.padding-small {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.margin-bottom {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.margin-bottom-large {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.margin-top {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.margin-top-small {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.border {
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
.border-bottom {
|
||||||
|
border-bottom: 1px solid var(--gruvbox-bg3);
|
||||||
|
}
|
||||||
|
.left-align {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.right-align {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.center-align {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.overflow {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.extra-large {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
.large {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.medium {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
/* Skeleton loading styles */
|
||||||
|
.skeleton-table {
|
||||||
|
max-width: 95%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(168, 153, 132, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(168, 153, 132, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-cell {
|
||||||
|
height: 1.2rem;
|
||||||
|
background: linear-gradient(90deg, rgba(168, 153, 132, 0.1) 25%, rgba(168, 153, 132, 0.2) 50%, rgba(168, 153, 132, 0.1) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading indicator */
|
||||||
|
.loading-spinner {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
margin: 20px auto;
|
||||||
|
border: 3px solid rgba(168, 153, 132, 0.3);
|
||||||
|
border-top: 3px solid #b8bb26; /* gruvbox green */
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request .htmx-indicator {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request.htmx-indicator {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve spacing in tables */
|
||||||
|
.overflow {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-skeleton {
|
||||||
|
background-color: var(--gruvbox-bg1);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in animation for loaded content */
|
||||||
|
.group-backups-table {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
.skeleton-text {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(90deg, rgba(168, 153, 132, 0.1) 25%, rgba(168, 153, 132, 0.2) 50%, rgba(168, 153, 132, 0.1) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: transparent !important;
|
||||||
|
width: 40px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton table styles remain the same */
|
||||||
|
.skeleton-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(168, 153, 132, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(168, 153, 132, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-cell {
|
||||||
|
height: 1.2rem;
|
||||||
|
background: linear-gradient(90deg, rgba(168, 153, 132, 0.1) 25%, rgba(168, 153, 132, 0.2) 50%, rgba(168, 153, 132, 0.1) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading indicator */
|
||||||
|
.loading-spinner {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
margin: 20px auto;
|
||||||
|
border: 3px solid rgba(168, 153, 132, 0.3);
|
||||||
|
border-top: 3px solid #b8bb26; /* gruvbox green */
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request .htmx-indicator {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request.htmx-indicator {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entry animation for both header and content */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-backups-table,
|
||||||
|
#group-header-* {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
.gruvbox-button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--gruvbox-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gruvbox-button:hover {
|
||||||
|
background-color: var(--gruvbox-bg3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gruvbox-button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline spinner for buttons */
|
||||||
|
.inline-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for table transitions */
|
||||||
|
.group-backups-table {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-swapping {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make the table header sticky */
|
||||||
|
.gruvbox-table thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background-color: var(--gruvbox-bg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons styling */
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
border: none;
|
||||||
|
transition: background-color 0.15s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-button {
|
||||||
|
background-color: var(--gruvbox-blue);
|
||||||
|
color: var(--gruvbox-bg-hard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button {
|
||||||
|
background-color: var(--gruvbox-green);
|
||||||
|
color: var(--gruvbox-bg-hard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make sure the table has enough width */
|
||||||
|
.gruvbox-table {
|
||||||
|
width: 100%;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure columns have appropriate widths */
|
||||||
|
.gruvbox-table th:last-child,
|
||||||
|
.gruvbox-table td:last-child {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for small screens */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Add these styles to your CSS file */
|
||||||
|
|
||||||
|
.restore-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-modal:before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-details {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(40, 40, 40, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gruvbox-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #1d2021;
|
||||||
|
border: 1px solid #3c3836;
|
||||||
|
color: #ebdbb2;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gruvbox-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #fabd2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
|
||||||
|
<body class="gruvbox-bg0 gruvbox-fg">
|
||||||
|
<header class="gruvbox-bg-hard padding">
|
||||||
|
<div class="responsive">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<a href="/" class="gruvbox-yellow no-underline">
|
||||||
|
<h1 class="large">Backea</h1>
|
||||||
|
</a>
|
||||||
|
<nav>
|
||||||
|
<ul style="display: flex; gap: 1rem;">
|
||||||
|
<li><a href="/" class="gruvbox-link">Home</a></li>
|
||||||
|
<li><a href="/about" class="gruvbox-link">About</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Notification area for displaying feedback -->
|
||||||
|
<div id="notification-area"></div>
|
||||||
|
|
||||||
|
<main class="padding">
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="gruvbox-bg-hard padding margin-top">
|
||||||
|
<div class="responsive">
|
||||||
|
<p class="text-center">
|
||||||
|
<small class="gruvbox-fg-dim">Backea - Unified Backup Dashboard</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
}
|
62
templates/layouts/base_templ.go
Normal file
62
templates/layouts/base_templ.go
Normal file
File diff suppressed because one or more lines are too long
39
templates/notifications.templ
Normal file
39
templates/notifications.templ
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Notification displays a temporary notification message
|
||||||
|
templ Notification(message string, notificationType string) {
|
||||||
|
<div id="notification"
|
||||||
|
class={ templ.Classes(
|
||||||
|
"notification",
|
||||||
|
templ.KV(notificationType, true),
|
||||||
|
)}
|
||||||
|
_="on load wait 4s then add .fade-out wait 1s then remove me">
|
||||||
|
<p>{ message }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success notification
|
||||||
|
templ SuccessNotification(message string) {
|
||||||
|
@Notification(message, "success")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error notification
|
||||||
|
templ ErrorNotification(message string) {
|
||||||
|
@Notification(message, "error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreSuccessResponse returns a response for successful restore operation
|
||||||
|
templ RestoreSuccessResponse(backupID string) {
|
||||||
|
<div hx-swap-oob="true:#notification-area">
|
||||||
|
@SuccessNotification("Backup restoration started successfully")
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreErrorResponse returns a response for failed restore operation
|
||||||
|
templ RestoreErrorResponse(errorMessage string) {
|
||||||
|
<div hx-swap-oob="true:#notification-area">
|
||||||
|
@ErrorNotification(fmt.Sprintf("Backup restoration failed: %s", errorMessage))
|
||||||
|
</div>
|
||||||
|
}
|
213
templates/notifications_templ.go
Normal file
213
templates/notifications_templ.go
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.833
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Notification displays a temporary notification message
|
||||||
|
func Notification(message string, notificationType string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
var templ_7745c5c3_Var2 = []any{templ.Classes(
|
||||||
|
"notification",
|
||||||
|
templ.KV(notificationType, true),
|
||||||
|
)}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"notification\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/notifications.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" _=\"on load wait 4s then add .fade-out wait 1s then remove me\"><p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/notifications.templ`, Line: 13, Col: 20}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success notification
|
||||||
|
func SuccessNotification(message string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var5 == nil {
|
||||||
|
templ_7745c5c3_Var5 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = Notification(message, "success").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error notification
|
||||||
|
func ErrorNotification(message string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var6 == nil {
|
||||||
|
templ_7745c5c3_Var6 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = Notification(message, "error").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreSuccessResponse returns a response for successful restore operation
|
||||||
|
func RestoreSuccessResponse(backupID string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var7 == nil {
|
||||||
|
templ_7745c5c3_Var7 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<div hx-swap-oob=\"true:#notification-area\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = SuccessNotification("Backup restoration started successfully").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreErrorResponse returns a response for failed restore operation
|
||||||
|
func RestoreErrorResponse(errorMessage string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var8 == nil {
|
||||||
|
templ_7745c5c3_Var8 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div hx-swap-oob=\"true:#notification-area\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = ErrorNotification(fmt.Sprintf("Backup restoration failed: %s", errorMessage)).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
Loading…
x
Reference in New Issue
Block a user