v0.11.8 — Per-App Cross-Drive Backup (3-2-1 rule)
New feature: backup app data to a secondary storage drive to satisfy
the "different media" requirement of the 3-2-1 backup rule.
- settings.go: CrossDriveBackup struct, AppBackupPrefs.CrossDrive field,
getter/setter methods, GetOrCreateCrossDrivePassword, preserves
cross-drive config when toggling nightly backup
- crossdrive.go (new): CrossDriveRunner with rsync and restic backends.
Validates destination (mount point, writable), prevents source/dest
overlap, per-app concurrency lock, persists last_run/status/size.
- main.go: wire CrossDriveRunner, register cross-drive-daily (03:30)
and cross-drive-weekly (04:30 Sundays) scheduler jobs
- router.go: 4 new API endpoints — save config, trigger run, get status,
run-all. Router now accepts Settings and CrossDriveRunner.
- server.go: Server struct accepts CrossDriveRunner, new web route
POST /settings/cross-backup/{name}
- handlers.go: deployHandler populates CrossDriveConfig, BackupDestPaths,
BackupDestWarning, AppBackupEnabled. settingsCrossBackupHandler saves
config. backupsHandler builds CrossDriveSummary, UnconfiguredApps,
CrossDriveWarnings for backup page.
- deploy.html: "Biztonsági mentés" card with destination/method/schedule
dropdowns, last-run status, manual trigger button, flash messages.
- backups.html: "Másolatok másik meghajtóra" section with per-app
status rows, unconfigured app warnings, "Összes futtatása most" button.
- style.css: margin-bottom fix for .deploy-stale-data, new cross-drive
card and list styles.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
@@ -20,17 +21,19 @@ import (
|
||||
|
||||
// Router handles all /api/* requests.
|
||||
type Router struct {
|
||||
cfg *config.Config
|
||||
stackMgr *stacks.Manager
|
||||
syncer *catalogsync.Syncer
|
||||
cpuCollector *system.CPUCollector
|
||||
backupMgr *backup.Manager
|
||||
metricsStore *metrics.MetricsStore
|
||||
logger *log.Logger
|
||||
cfg *config.Config
|
||||
sett *settings.Settings
|
||||
stackMgr *stacks.Manager
|
||||
syncer *catalogsync.Syncer
|
||||
cpuCollector *system.CPUCollector
|
||||
backupMgr *backup.Manager
|
||||
crossDriveRunner *backup.CrossDriveRunner
|
||||
metricsStore *metrics.MetricsStore
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewRouter(cfg *config.Config, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore, logger *log.Logger) *Router {
|
||||
return &Router{cfg: cfg, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, logger: logger}
|
||||
func NewRouter(cfg *config.Config, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, logger *log.Logger) *Router {
|
||||
return &Router{cfg: cfg, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, logger: logger}
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
@@ -98,6 +101,22 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
|
||||
r.deleteStack(w, req, trimSegment(path, "/stacks/"))
|
||||
|
||||
// POST /api/stacks/{name}/cross-backup — save cross-drive config
|
||||
case hasSuffix(path, "/cross-backup") && req.Method == http.MethodPost && !hasSuffix(path, "/cross-backup/run") && !hasSuffix(path, "/cross-backup/status"):
|
||||
r.saveCrossBackupConfig(w, req, extractName(path, "/cross-backup"))
|
||||
|
||||
// POST /api/stacks/{name}/cross-backup/run — trigger manual run
|
||||
case hasSuffix(path, "/cross-backup/run") && req.Method == http.MethodPost:
|
||||
r.triggerCrossBackup(w, req, extractName(path, "/cross-backup/run"))
|
||||
|
||||
// GET /api/stacks/{name}/cross-backup/status — poll status
|
||||
case hasSuffix(path, "/cross-backup/status") && req.Method == http.MethodGet:
|
||||
r.getCrossBackupStatus(w, req, extractName(path, "/cross-backup/status"))
|
||||
|
||||
// POST /api/backup/cross-drive/run-all — trigger all scheduled cross-drive backups
|
||||
case path == "/backup/cross-drive/run-all" && req.Method == http.MethodPost:
|
||||
r.triggerAllCrossBackups(w, req)
|
||||
|
||||
// POST /api/sync — trigger immediate catalog sync
|
||||
case path == "/sync" && req.Method == http.MethodPost:
|
||||
r.triggerSync(w, req)
|
||||
@@ -536,6 +555,129 @@ func (r *Router) metricsSysInfo(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
|
||||
}
|
||||
|
||||
// --- Cross-drive backup handlers ---
|
||||
|
||||
func (r *Router) saveCrossBackupConfig(w http.ResponseWriter, req *http.Request, name string) {
|
||||
if r.crossDriveRunner == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
||||
return
|
||||
}
|
||||
limitBody(w, req)
|
||||
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Method string `json:"method"`
|
||||
DestinationPath string `json:"destination_path"`
|
||||
Schedule string `json:"schedule"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate method
|
||||
if body.Method != "rsync" && body.Method != "restic" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "method must be 'rsync' or 'restic'"})
|
||||
return
|
||||
}
|
||||
// Validate schedule
|
||||
if body.Schedule != "daily" && body.Schedule != "weekly" && body.Schedule != "manual" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "schedule must be 'daily', 'weekly', or 'manual'"})
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve existing runtime status
|
||||
existing := r.sett.GetCrossDriveConfig(name)
|
||||
var lastRun, lastStatus, lastError, lastDuration, lastSize string
|
||||
if existing != nil {
|
||||
lastRun, lastStatus, lastError, lastDuration, lastSize =
|
||||
existing.LastRun, existing.LastStatus, existing.LastError, existing.LastDuration, existing.LastSizeHuman
|
||||
}
|
||||
|
||||
cfg := &settings.CrossDriveBackup{
|
||||
Enabled: body.Enabled,
|
||||
Method: body.Method,
|
||||
DestinationPath: body.DestinationPath,
|
||||
Schedule: body.Schedule,
|
||||
LastRun: lastRun,
|
||||
LastStatus: lastStatus,
|
||||
LastError: lastError,
|
||||
LastDuration: lastDuration,
|
||||
LastSizeHuman: lastSize,
|
||||
}
|
||||
|
||||
if err := r.sett.SetCrossDriveConfig(name, cfg); err != nil {
|
||||
r.logger.Printf("[API] Failed to save cross-drive config for %s: %v", name, err)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Printf("[API] Cross-drive backup config saved for %s: method=%s dest=%s schedule=%s",
|
||||
name, body.Method, body.DestinationPath, body.Schedule)
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Cross-drive backup configuration saved"})
|
||||
}
|
||||
|
||||
func (r *Router) triggerCrossBackup(w http.ResponseWriter, req *http.Request, name string) {
|
||||
if r.crossDriveRunner == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
||||
return
|
||||
}
|
||||
if r.crossDriveRunner.IsRunning(name) {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
||||
return
|
||||
}
|
||||
|
||||
r.logger.Printf("[API] Cross-drive backup triggered for: %s", name)
|
||||
go func() {
|
||||
if err := r.crossDriveRunner.RunAppBackup(context.Background(), name); err != nil {
|
||||
r.logger.Printf("[API] Cross-drive backup failed for %s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
|
||||
}
|
||||
|
||||
func (r *Router) getCrossBackupStatus(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
cfg := r.sett.GetCrossDriveConfig(name)
|
||||
if cfg == nil {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"configured": false}})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
|
||||
"configured": true,
|
||||
"enabled": cfg.Enabled,
|
||||
"method": cfg.Method,
|
||||
"schedule": cfg.Schedule,
|
||||
"running": r.crossDriveRunner != nil && r.crossDriveRunner.IsRunning(name),
|
||||
"last_run": cfg.LastRun,
|
||||
"last_status": cfg.LastStatus,
|
||||
"last_error": cfg.LastError,
|
||||
"last_duration": cfg.LastDuration,
|
||||
"last_size": cfg.LastSizeHuman,
|
||||
}})
|
||||
}
|
||||
|
||||
func (r *Router) triggerAllCrossBackups(w http.ResponseWriter, _ *http.Request) {
|
||||
if r.crossDriveRunner == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
|
||||
return
|
||||
}
|
||||
r.logger.Println("[API] All cross-drive backups triggered")
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
if err := r.crossDriveRunner.RunAllScheduled(ctx, "daily"); err != nil {
|
||||
r.logger.Printf("[API] Cross-drive run-all error: %v", err)
|
||||
}
|
||||
if err := r.crossDriveRunner.RunAllScheduled(ctx, "weekly"); err != nil {
|
||||
r.logger.Printf("[API] Cross-drive run-all weekly error: %v", err)
|
||||
}
|
||||
if err := r.crossDriveRunner.RunAllScheduled(ctx, "manual"); err != nil {
|
||||
r.logger.Printf("[API] Cross-drive run-all manual error: %v", err)
|
||||
}
|
||||
}()
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Összes mentés elindítva"})
|
||||
}
|
||||
|
||||
// parseTimeRange reads range or from/to query params.
|
||||
func parseTimeRange(req *http.Request) (from, to time.Time) {
|
||||
to = time.Now()
|
||||
|
||||
@@ -53,6 +53,21 @@ type SnapshotRecord struct {
|
||||
HasStats bool `json:"has_stats"` // false for historical entries loaded from restic
|
||||
}
|
||||
|
||||
// CrossDriveSummaryItem holds display data for one app's cross-drive backup.
|
||||
type CrossDriveSummaryItem struct {
|
||||
StackName string
|
||||
DisplayName string
|
||||
Method string // "rsync" or "restic"
|
||||
MethodLabel string // "Egyszerű másolat" or "Restic"
|
||||
DestPath string
|
||||
DestLabel string // storage path label
|
||||
Schedule string
|
||||
ScheduleLabel string // "Naponta" or "Hetente" or "Kézi"
|
||||
LastStatus string // "ok", "error", "running", ""
|
||||
LastRunShort string // formatted short time e.g. "03:15"
|
||||
SizeHuman string
|
||||
}
|
||||
|
||||
// FullBackupStatus contains everything the backup page needs.
|
||||
type FullBackupStatus struct {
|
||||
Enabled bool
|
||||
@@ -88,6 +103,11 @@ type FullBackupStatus struct {
|
||||
// App data backup
|
||||
AppDataInfo []AppBackupInfo
|
||||
|
||||
// Cross-drive backup summary
|
||||
CrossDriveSummary []CrossDriveSummaryItem
|
||||
UnconfiguredApps []CrossDriveSummaryItem // apps with HDD data but no cross-drive config
|
||||
CrossDriveWarnings []string // destination health warnings
|
||||
|
||||
// Flash messages (set by handlers, passed through redirect)
|
||||
FlashSuccess string
|
||||
FlashError string
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
// CrossDriveRunner handles per-app backup to secondary storage.
|
||||
type CrossDriveRunner struct {
|
||||
sett *settings.Settings
|
||||
stackProvider StackDataProvider
|
||||
logger *log.Logger
|
||||
mu sync.Mutex
|
||||
running map[string]bool // per-app running state
|
||||
}
|
||||
|
||||
// NewCrossDriveRunner creates a new CrossDriveRunner.
|
||||
func NewCrossDriveRunner(sett *settings.Settings, provider StackDataProvider, logger *log.Logger) *CrossDriveRunner {
|
||||
return &CrossDriveRunner{
|
||||
sett: sett,
|
||||
stackProvider: provider,
|
||||
logger: logger,
|
||||
running: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// RunAppBackup runs cross-drive backup for a single app.
|
||||
func (r *CrossDriveRunner) RunAppBackup(ctx context.Context, stackName string) error {
|
||||
cfg := r.sett.GetCrossDriveConfig(stackName)
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
return fmt.Errorf("cross-drive backup not configured or disabled for %s", stackName)
|
||||
}
|
||||
|
||||
// Prevent concurrent runs for the same app
|
||||
r.mu.Lock()
|
||||
if r.running[stackName] {
|
||||
r.mu.Unlock()
|
||||
return fmt.Errorf("cross-drive backup already running for %s", stackName)
|
||||
}
|
||||
r.running[stackName] = true
|
||||
r.mu.Unlock()
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
r.running[stackName] = false
|
||||
r.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Mark as running in settings
|
||||
_ = r.sett.UpdateCrossDriveStatus(stackName, func(c *settings.CrossDriveBackup) {
|
||||
c.LastStatus = "running"
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
r.logger.Printf("[INFO] Cross-drive backup starting: %s → %s (method: %s)",
|
||||
stackName, cfg.DestinationPath, cfg.Method)
|
||||
|
||||
if err := r.ValidateDestination(cfg.DestinationPath); err != nil {
|
||||
r.updateStatus(stackName, "error", err.Error(), time.Since(start), "")
|
||||
return fmt.Errorf("destination validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Resolve HDD mounts for this app
|
||||
mounts := r.stackProvider.GetStackHDDMounts(stackName)
|
||||
if len(mounts) == 0 {
|
||||
r.updateStatus(stackName, "error", "no HDD data paths found for this app", time.Since(start), "")
|
||||
return fmt.Errorf("no HDD data paths found for %s", stackName)
|
||||
}
|
||||
|
||||
// Safety: destination must not overlap with any source
|
||||
for _, m := range mounts {
|
||||
if system.PathsOverlap(cfg.DestinationPath, m) {
|
||||
msg := fmt.Sprintf("destination %s overlaps with source %s — aborted", cfg.DestinationPath, m)
|
||||
r.updateStatus(stackName, "error", msg, time.Since(start), "")
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
var runErr error
|
||||
switch cfg.Method {
|
||||
case "rsync":
|
||||
runErr = r.runRsyncBackup(ctx, stackName, cfg.DestinationPath, mounts)
|
||||
case "restic":
|
||||
runErr = r.runResticBackup(ctx, stackName, cfg.DestinationPath, mounts)
|
||||
default:
|
||||
runErr = fmt.Errorf("unknown backup method: %s", cfg.Method)
|
||||
}
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
if runErr != nil {
|
||||
r.logger.Printf("[ERROR] Cross-drive backup failed: %s: %v", stackName, runErr)
|
||||
r.updateStatus(stackName, "error", runErr.Error(), duration, "")
|
||||
return runErr
|
||||
}
|
||||
|
||||
// Calculate backup size
|
||||
var sizeHuman string
|
||||
if cfg.Method == "rsync" {
|
||||
destDir := filepath.Join(cfg.DestinationPath, "backups", "rsync", stackName)
|
||||
if sz, err := dirSizeBytes(destDir); err == nil {
|
||||
sizeHuman = humanizeBytes(sz)
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.Printf("[INFO] Cross-drive backup completed: %s (%s)", stackName, duration.Round(time.Second))
|
||||
r.updateStatus(stackName, "ok", "", duration, sizeHuman)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunAllScheduled runs cross-drive backups for all apps matching the schedule.
|
||||
// Runs sequentially (disk I/O bound).
|
||||
func (r *CrossDriveRunner) RunAllScheduled(ctx context.Context, schedule string) error {
|
||||
configs := r.sett.GetAllCrossDriveConfigs()
|
||||
if len(configs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs []string
|
||||
for stackName, cfg := range configs {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
if cfg.Schedule != schedule {
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if err := r.RunAppBackup(ctx, stackName); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", stackName, err))
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("cross-drive backup errors: %s", strings.Join(errs, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning returns true if the given app's backup is currently running.
|
||||
func (r *CrossDriveRunner) IsRunning(stackName string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.running[stackName]
|
||||
}
|
||||
|
||||
// ValidateDestination checks that the destination path is a mount point, writable,
|
||||
// and has sufficient free space (at least 100MB).
|
||||
func (r *CrossDriveRunner) ValidateDestination(path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("destination path is empty")
|
||||
}
|
||||
if !system.IsMountPoint(path) {
|
||||
return fmt.Errorf("destination %s is not a mount point", path)
|
||||
}
|
||||
if !system.IsWritable(path) {
|
||||
return fmt.Errorf("destination %s is not writable", path)
|
||||
}
|
||||
di := system.GetDiskUsage(path)
|
||||
if di != nil && di.AvailGB < 0.1 {
|
||||
return fmt.Errorf("destination %s has insufficient free space (%.1f GB)", path, di.AvailGB)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- rsync ---
|
||||
|
||||
func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBase string, mounts []string) error {
|
||||
destDir := filepath.Join(destBase, "backups", "rsync", stackName)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating rsync dest dir: %w", err)
|
||||
}
|
||||
|
||||
for _, srcMount := range mounts {
|
||||
// Preserve directory structure: strip the storage path prefix to get relative subpath
|
||||
// e.g., /mnt/hdd_placeholder/storage/immich/ → storage/immich/
|
||||
rel := srcMount
|
||||
// Find the topmost non-root segment of the mount path (after the mount point itself)
|
||||
// Use a simple approach: keep everything from the first significant segment after /mnt/...
|
||||
parts := strings.SplitN(strings.TrimPrefix(srcMount, "/"), "/", 3)
|
||||
if len(parts) >= 3 {
|
||||
rel = parts[2] // e.g., "storage/immich"
|
||||
} else {
|
||||
rel = filepath.Base(srcMount)
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(destDir, rel)
|
||||
if err := os.MkdirAll(dstPath, 0755); err != nil {
|
||||
return fmt.Errorf("creating rsync destination: %w", err)
|
||||
}
|
||||
|
||||
// Ensure trailing slash on source for rsync semantics (copy contents, not the dir itself)
|
||||
src := strings.TrimRight(srcMount, "/") + "/"
|
||||
dst := strings.TrimRight(dstPath, "/") + "/"
|
||||
|
||||
cmd := exec.CommandContext(ctx, "rsync", "-a", "--delete", src, dst)
|
||||
r.logger.Printf("[DEBUG] rsync: %s → %s", src, dst)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("rsync failed for %s: %v (%s)", srcMount, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- restic ---
|
||||
|
||||
func (r *CrossDriveRunner) runResticBackup(ctx context.Context, stackName, destBase string, mounts []string) error {
|
||||
repoPath := filepath.Join(destBase, "backups", "restic")
|
||||
|
||||
// Get or create the cross-drive restic password
|
||||
password, err := r.sett.GetOrCreateCrossDrivePassword()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting restic password: %w", err)
|
||||
}
|
||||
|
||||
// Write password to a temp file (restic requires --password-file or env var)
|
||||
pwFile, err := os.CreateTemp("", "felhom-crossdrive-pw-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating password file: %w", err)
|
||||
}
|
||||
defer os.Remove(pwFile.Name())
|
||||
if _, err := pwFile.WriteString(password); err != nil {
|
||||
pwFile.Close()
|
||||
return fmt.Errorf("writing password file: %w", err)
|
||||
}
|
||||
pwFile.Close()
|
||||
|
||||
// Ensure repo is initialized
|
||||
if err := r.ensureResticRepo(ctx, repoPath, pwFile.Name()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run restic backup
|
||||
args := []string{
|
||||
"backup", "--repo", repoPath,
|
||||
"--password-file", pwFile.Name(),
|
||||
"--tag", stackName,
|
||||
"--tag", "cross-drive",
|
||||
}
|
||||
args = append(args, mounts...)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "restic", args...)
|
||||
r.logger.Printf("[DEBUG] restic backup: %v", args)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("restic backup failed: %v (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *CrossDriveRunner) ensureResticRepo(ctx context.Context, repoPath, pwFile string) error {
|
||||
// Check if repo config exists
|
||||
if _, err := os.Stat(filepath.Join(repoPath, "config")); err == nil {
|
||||
return nil // already initialized
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(repoPath, 0755); err != nil {
|
||||
return fmt.Errorf("creating restic repo dir: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "restic", "init", "--repo", repoPath, "--password-file", pwFile)
|
||||
r.logger.Printf("[INFO] Initializing cross-drive restic repo at %s", repoPath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("restic init failed: %v (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func (r *CrossDriveRunner) updateStatus(stackName, status, errMsg string, duration time.Duration, sizeHuman string) {
|
||||
_ = r.sett.UpdateCrossDriveStatus(stackName, func(c *settings.CrossDriveBackup) {
|
||||
c.LastRun = time.Now().UTC().Format(time.RFC3339)
|
||||
c.LastStatus = status
|
||||
c.LastError = errMsg
|
||||
c.LastDuration = duration.Round(time.Second).String()
|
||||
if sizeHuman != "" {
|
||||
c.LastSizeHuman = sizeHuman
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// dirSizeBytes returns the total byte size of all files under path.
|
||||
func dirSizeBytes(path string) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
return total, err
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +13,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// cryptoRandRead is a var so tests can stub it.
|
||||
var cryptoRandRead = func(b []byte) (int, error) { return io.ReadFull(rand.Reader, b) }
|
||||
|
||||
// Settings holds customer-modifiable overrides and cached state.
|
||||
// Persisted as a single JSON file (settings.json) in the data directory.
|
||||
type Settings struct {
|
||||
@@ -32,11 +37,33 @@ type Settings struct {
|
||||
|
||||
// Storage paths registry
|
||||
StoragePaths []StoragePath `json:"storage_paths,omitempty"`
|
||||
|
||||
// Cross-drive restic repo password (auto-generated on first use)
|
||||
CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"`
|
||||
}
|
||||
|
||||
// AppBackupPrefs holds per-app backup toggle state.
|
||||
type AppBackupPrefs struct {
|
||||
// Existing: includes app data in nightly restic (same drive)
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Cross-drive backup to secondary storage
|
||||
CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"`
|
||||
}
|
||||
|
||||
// CrossDriveBackup configures per-app backup to a secondary drive.
|
||||
type CrossDriveBackup struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Method string `json:"method"` // "rsync" or "restic"
|
||||
DestinationPath string `json:"destination_path"` // e.g., "/mnt/hdd_1"
|
||||
Schedule string `json:"schedule"` // "daily", "weekly", "manual"
|
||||
|
||||
// Runtime state (updated by backup runner, persisted for display)
|
||||
LastRun string `json:"last_run,omitempty"` // RFC3339
|
||||
LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running"
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
LastDuration string `json:"last_duration,omitempty"` // "2m34s"
|
||||
LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"
|
||||
}
|
||||
|
||||
// StoragePath represents a registered external storage location.
|
||||
@@ -204,13 +231,16 @@ func (s *Settings) IsAppBackupEnabled(stackName string) bool {
|
||||
}
|
||||
|
||||
// SetAppBackup enables or disables backup for a stack and saves to disk.
|
||||
// Preserves existing CrossDrive config.
|
||||
func (s *Settings) SetAppBackup(stackName string, enabled bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.AppBackup == nil {
|
||||
s.AppBackup = make(map[string]AppBackupPrefs)
|
||||
}
|
||||
s.AppBackup[stackName] = AppBackupPrefs{Enabled: enabled}
|
||||
existing := s.AppBackup[stackName]
|
||||
existing.Enabled = enabled
|
||||
s.AppBackup[stackName] = existing
|
||||
return s.save()
|
||||
}
|
||||
|
||||
@@ -229,16 +259,111 @@ func (s *Settings) GetAppBackupMap() map[string]bool {
|
||||
}
|
||||
|
||||
// SetAppBackupBulk updates backup prefs for all stacks at once and saves to disk.
|
||||
// Preserves existing CrossDrive configs.
|
||||
func (s *Settings) SetAppBackupBulk(prefs map[string]bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.AppBackup = make(map[string]AppBackupPrefs, len(prefs))
|
||||
newMap := make(map[string]AppBackupPrefs, len(prefs))
|
||||
for name, enabled := range prefs {
|
||||
s.AppBackup[name] = AppBackupPrefs{Enabled: enabled}
|
||||
existing := s.AppBackup[name] // preserves CrossDrive
|
||||
existing.Enabled = enabled
|
||||
newMap[name] = existing
|
||||
}
|
||||
s.AppBackup = newMap
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// GetAppBackupPrefs returns the full AppBackupPrefs for a stack.
|
||||
func (s *Settings) GetAppBackupPrefs(stackName string) (AppBackupPrefs, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.AppBackup == nil {
|
||||
return AppBackupPrefs{}, false
|
||||
}
|
||||
prefs, ok := s.AppBackup[stackName]
|
||||
return prefs, ok
|
||||
}
|
||||
|
||||
// GetCrossDriveConfig returns the cross-drive backup config for a stack (nil if not set).
|
||||
func (s *Settings) GetCrossDriveConfig(stackName string) *CrossDriveBackup {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.AppBackup == nil {
|
||||
return nil
|
||||
}
|
||||
prefs, ok := s.AppBackup[stackName]
|
||||
if !ok || prefs.CrossDrive == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *prefs.CrossDrive
|
||||
return &cp
|
||||
}
|
||||
|
||||
// SetCrossDriveConfig saves (or clears) the cross-drive backup config for a stack.
|
||||
func (s *Settings) SetCrossDriveConfig(stackName string, cfg *CrossDriveBackup) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.AppBackup == nil {
|
||||
s.AppBackup = make(map[string]AppBackupPrefs)
|
||||
}
|
||||
existing := s.AppBackup[stackName]
|
||||
existing.CrossDrive = cfg
|
||||
s.AppBackup[stackName] = existing
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// UpdateCrossDriveStatus updates runtime status fields for a cross-drive backup in-place.
|
||||
// fn receives a pointer to the CrossDriveBackup (creates one if nil) and may mutate it.
|
||||
func (s *Settings) UpdateCrossDriveStatus(stackName string, fn func(*CrossDriveBackup)) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.AppBackup == nil {
|
||||
s.AppBackup = make(map[string]AppBackupPrefs)
|
||||
}
|
||||
existing := s.AppBackup[stackName]
|
||||
if existing.CrossDrive == nil {
|
||||
return nil // don't create config from thin air — just skip status update
|
||||
}
|
||||
fn(existing.CrossDrive)
|
||||
s.AppBackup[stackName] = existing
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// GetAllCrossDriveConfigs returns all apps with a cross-drive config (enabled or not).
|
||||
func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result := make(map[string]*CrossDriveBackup)
|
||||
for name, prefs := range s.AppBackup {
|
||||
if prefs.CrossDrive != nil {
|
||||
cp := *prefs.CrossDrive
|
||||
result[name] = &cp
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetOrCreateCrossDrivePassword returns the cross-drive restic password,
|
||||
// generating and persisting one if it doesn't exist yet.
|
||||
func (s *Settings) GetOrCreateCrossDrivePassword() (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.CrossDriveResticPassword != "" {
|
||||
return s.CrossDriveResticPassword, nil
|
||||
}
|
||||
// Generate a random 32-byte password
|
||||
buf := make([]byte, 32)
|
||||
_, err := cryptoRandRead(buf)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generating cross-drive restic password: %w", err)
|
||||
}
|
||||
s.CrossDriveResticPassword = fmt.Sprintf("%x", buf)
|
||||
if err := s.save(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.CrossDriveResticPassword, nil
|
||||
}
|
||||
|
||||
// --- Storage Paths ---
|
||||
|
||||
// GetStoragePaths returns a copy of all registered storage paths.
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
|
||||
// DeployStoragePath extends StoragePath with free space data for the deploy dropdown.
|
||||
type DeployStoragePath struct {
|
||||
settings.StoragePath
|
||||
@@ -204,6 +206,45 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
if len(staleData) > 0 {
|
||||
data["StaleData"] = staleData
|
||||
}
|
||||
|
||||
// Cross-drive backup config for this app
|
||||
crossCfg := s.settings.GetCrossDriveConfig(name)
|
||||
data["CrossDriveConfig"] = crossCfg
|
||||
|
||||
// Other storage paths for destination dropdown (exclude the app's current storage path)
|
||||
currentPath := ""
|
||||
if storageInfo != nil {
|
||||
currentPath = storageInfo.Path
|
||||
}
|
||||
var destPaths []DeployStoragePath
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Path == currentPath {
|
||||
continue // skip the app's current storage — must be a DIFFERENT physical device
|
||||
}
|
||||
dp := DeployStoragePath{StoragePath: sp}
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
dp.FreeHuman = formatFreeSpace(di.AvailGB)
|
||||
if di.TotalGB > 0 {
|
||||
dp.FreePercent = di.AvailGB / di.TotalGB * 100
|
||||
}
|
||||
}
|
||||
destPaths = append(destPaths, dp)
|
||||
}
|
||||
data["BackupDestPaths"] = destPaths
|
||||
|
||||
// Destination health warning
|
||||
if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" {
|
||||
if !system.IsMountPoint(crossCfg.DestinationPath) || !system.IsWritable(crossCfg.DestinationPath) {
|
||||
data["BackupDestWarning"] = fmt.Sprintf(
|
||||
"A cél tárhely (%s) nem elérhető! Ellenőrizd a meghajtó csatlakozását.",
|
||||
crossCfg.DestinationPath,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Nightly backup toggle state
|
||||
appBackupEnabled := s.settings.IsAppBackupEnabled(name)
|
||||
data["AppBackupEnabled"] = appBackupEnabled
|
||||
}
|
||||
|
||||
// Memory info for deploy page (only for non-deployed apps)
|
||||
@@ -243,6 +284,14 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
data["MemoryInfo"] = memInfo
|
||||
}
|
||||
|
||||
// Flash messages from cross-drive backup save redirect
|
||||
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||||
data["FlashSuccess"] = flash
|
||||
}
|
||||
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
|
||||
data["FlashError"] = flashErr
|
||||
}
|
||||
|
||||
s.render(w, "deploy", data)
|
||||
}
|
||||
|
||||
@@ -352,6 +401,71 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build cross-drive summary
|
||||
crossConfigs := s.settings.GetAllCrossDriveConfigs()
|
||||
|
||||
// Build label lookup for dest paths
|
||||
destLabels := make(map[string]string)
|
||||
for _, sp := range storagePaths {
|
||||
destLabels[sp.Path] = sp.Label
|
||||
}
|
||||
|
||||
for _, app := range fullStatus.AppDataInfo {
|
||||
if !app.HasHDDData {
|
||||
continue
|
||||
}
|
||||
cfg, hasCfg := crossConfigs[app.StackName]
|
||||
if !hasCfg || cfg == nil {
|
||||
fullStatus.UnconfiguredApps = append(fullStatus.UnconfiguredApps, backup.CrossDriveSummaryItem{
|
||||
StackName: app.StackName,
|
||||
DisplayName: app.DisplayName,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
item := backup.CrossDriveSummaryItem{
|
||||
StackName: app.StackName,
|
||||
DisplayName: app.DisplayName,
|
||||
Method: cfg.Method,
|
||||
DestPath: cfg.DestinationPath,
|
||||
DestLabel: destLabels[cfg.DestinationPath],
|
||||
Schedule: cfg.Schedule,
|
||||
LastStatus: cfg.LastStatus,
|
||||
SizeHuman: cfg.LastSizeHuman,
|
||||
}
|
||||
switch cfg.Method {
|
||||
case "rsync":
|
||||
item.MethodLabel = "rsync"
|
||||
case "restic":
|
||||
item.MethodLabel = "restic"
|
||||
default:
|
||||
item.MethodLabel = cfg.Method
|
||||
}
|
||||
switch cfg.Schedule {
|
||||
case "daily":
|
||||
item.ScheduleLabel = "Naponta"
|
||||
case "weekly":
|
||||
item.ScheduleLabel = "Hetente"
|
||||
default:
|
||||
item.ScheduleLabel = "Kézi"
|
||||
}
|
||||
if cfg.LastRun != "" {
|
||||
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
|
||||
loc, _ := time.LoadLocation("Europe/Budapest")
|
||||
item.LastRunShort = t.In(loc).Format("01-02 15:04")
|
||||
}
|
||||
}
|
||||
fullStatus.CrossDriveSummary = append(fullStatus.CrossDriveSummary, item)
|
||||
|
||||
// Destination health warning
|
||||
if cfg.Enabled && cfg.DestinationPath != "" {
|
||||
if !system.IsMountPoint(cfg.DestinationPath) || !system.IsWritable(cfg.DestinationPath) {
|
||||
fullStatus.CrossDriveWarnings = append(fullStatus.CrossDriveWarnings,
|
||||
fmt.Sprintf("⚠️ %s mentési célja (%s) nem elérhető!", app.DisplayName, cfg.DestinationPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data["Backup"] = fullStatus
|
||||
|
||||
// Restic password for display
|
||||
@@ -399,6 +513,56 @@ func (s *Server) settingsAppBackupHandler(w http.ResponseWriter, r *http.Request
|
||||
http.Redirect(w, r, "/backups?flash=Alkalmaz%C3%A1s+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1sok+mentve.", http.StatusFound)
|
||||
}
|
||||
|
||||
// settingsCrossBackupHandler handles POST /settings/cross-backup/{name}
|
||||
// Saves or updates the cross-drive backup configuration for an app.
|
||||
func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
enabled := r.FormValue("cross_drive_enabled") == "on"
|
||||
method := r.FormValue("cross_drive_method")
|
||||
destPath := r.FormValue("cross_drive_dest")
|
||||
schedule := r.FormValue("cross_drive_schedule")
|
||||
|
||||
// Validate method and schedule
|
||||
if method != "rsync" && method != "restic" {
|
||||
method = "rsync"
|
||||
}
|
||||
if schedule != "daily" && schedule != "weekly" && schedule != "manual" {
|
||||
schedule = "daily"
|
||||
}
|
||||
|
||||
// Preserve existing runtime status fields
|
||||
existing := s.settings.GetCrossDriveConfig(name)
|
||||
|
||||
var cfg *settings.CrossDriveBackup
|
||||
if destPath != "" {
|
||||
cfg = &settings.CrossDriveBackup{
|
||||
Enabled: enabled,
|
||||
Method: method,
|
||||
DestinationPath: destPath,
|
||||
Schedule: schedule,
|
||||
}
|
||||
if existing != nil {
|
||||
cfg.LastRun = existing.LastRun
|
||||
cfg.LastStatus = existing.LastStatus
|
||||
cfg.LastError = existing.LastError
|
||||
cfg.LastDuration = existing.LastDuration
|
||||
cfg.LastSizeHuman = existing.LastSizeHuman
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.settings.SetCrossDriveConfig(name, cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to save cross-drive config for %s: %v", name, err)
|
||||
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error=Hiba+a+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+ment%C3%A9sakor", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Cross-drive backup config saved for %s: method=%s dest=%s schedule=%s enabled=%v",
|
||||
name, method, destPath, schedule, enabled)
|
||||
|
||||
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash=Ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+mentve.", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
|
||||
@@ -20,17 +20,18 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
stackMgr *stacks.Manager
|
||||
cpuCollector *system.CPUCollector
|
||||
backupMgr *backup.Manager
|
||||
scheduler *scheduler.Scheduler
|
||||
settings *settings.Settings
|
||||
alertManager *AlertManager
|
||||
notifier *notify.Notifier
|
||||
logger *log.Logger
|
||||
version string
|
||||
tmpl *template.Template
|
||||
cfg *config.Config
|
||||
stackMgr *stacks.Manager
|
||||
cpuCollector *system.CPUCollector
|
||||
backupMgr *backup.Manager
|
||||
crossDriveRunner *backup.CrossDriveRunner
|
||||
scheduler *scheduler.Scheduler
|
||||
settings *settings.Settings
|
||||
alertManager *AlertManager
|
||||
notifier *notify.Notifier
|
||||
logger *log.Logger
|
||||
version string
|
||||
tmpl *template.Template
|
||||
|
||||
sessions map[string]*session
|
||||
sessionsMu sync.RWMutex
|
||||
@@ -41,20 +42,21 @@ type Server struct {
|
||||
diskJob *activeDiskJob
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, logger *log.Logger, version string) *Server {
|
||||
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, logger *log.Logger, version string) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
stackMgr: stackMgr,
|
||||
cpuCollector: cpuCollector,
|
||||
backupMgr: backupMgr,
|
||||
scheduler: sched,
|
||||
settings: sett,
|
||||
alertManager: alertMgr,
|
||||
notifier: notif,
|
||||
logger: logger,
|
||||
version: version,
|
||||
sessions: make(map[string]*session),
|
||||
done: make(chan struct{}),
|
||||
cfg: cfg,
|
||||
stackMgr: stackMgr,
|
||||
cpuCollector: cpuCollector,
|
||||
backupMgr: backupMgr,
|
||||
crossDriveRunner: crossDrive,
|
||||
scheduler: sched,
|
||||
settings: sett,
|
||||
alertManager: alertMgr,
|
||||
notifier: notif,
|
||||
logger: logger,
|
||||
version: version,
|
||||
sessions: make(map[string]*session),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
s.loadTemplates()
|
||||
go s.cleanupSessions()
|
||||
@@ -110,6 +112,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.settingsStorageLabelHandler(w, r)
|
||||
case path == "/settings/app-backup" && r.Method == http.MethodPost:
|
||||
s.settingsAppBackupHandler(w, r)
|
||||
case strings.HasPrefix(path, "/settings/cross-backup/") && r.Method == http.MethodPost:
|
||||
name := strings.TrimPrefix(path, "/settings/cross-backup/")
|
||||
s.settingsCrossBackupHandler(w, r, name)
|
||||
case path == "/backup/restore" && r.Method == http.MethodPost:
|
||||
s.backupRestoreHandler(w, r)
|
||||
case path == "/settings/storage/init":
|
||||
|
||||
@@ -283,6 +283,57 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Section 4b: Cross-drive backups -->
|
||||
{{if or .Backup.CrossDriveSummary .Backup.UnconfiguredApps}}
|
||||
<div class="backup-section-card">
|
||||
<h3>Másolatok másik meghajtóra</h3>
|
||||
<p class="backup-section-desc">Alkalmazás adatok biztonsági másolata külső meghajtóra (3-2-1 szabály).</p>
|
||||
|
||||
{{if .Backup.CrossDriveWarnings}}
|
||||
<div style="margin-bottom:1rem">
|
||||
{{range .Backup.CrossDriveWarnings}}
|
||||
<div class="alert alert-warning" style="margin-bottom:.5rem">{{.}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Backup.CrossDriveSummary}}
|
||||
<div class="cross-drive-list" style="margin-bottom:1rem">
|
||||
{{range .Backup.CrossDriveSummary}}
|
||||
<div class="cross-drive-item">
|
||||
<div class="cross-drive-header">
|
||||
<a href="/stacks/{{.StackName}}/deploy" class="cross-drive-name">{{.DisplayName}}</a>
|
||||
<div class="cross-drive-meta">
|
||||
<span class="meta-badge">{{.MethodLabel}}</span>
|
||||
{{if .DestLabel}}<span class="meta-badge meta-badge-storage">→ {{.DestLabel}}</span>
|
||||
{{else if .DestPath}}<span class="meta-badge meta-badge-storage">→ {{.DestPath}}</span>{{end}}
|
||||
{{if eq .LastStatus "ok"}}<span class="meta-badge meta-badge-ok">✅ {{.LastRunShort}}</span>
|
||||
{{else if eq .LastStatus "error"}}<span class="meta-badge meta-badge-fail">❌ Hiba</span>
|
||||
{{else if eq .LastStatus "running"}}<span class="meta-badge">⏳ Fut...</span>
|
||||
{{else}}<span class="meta-badge" style="color:var(--text-muted)">⏰ {{.ScheduleLabel}}</span>{{end}}
|
||||
{{if .SizeHuman}}<span class="mono" style="font-size:.8rem;color:var(--text-muted)">{{.SizeHuman}}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Backup.UnconfiguredApps}}
|
||||
<div style="font-size:.85rem;color:var(--yellow);margin-bottom:1rem">
|
||||
⚠️ {{len .Backup.UnconfiguredApps}} alkalmazáshoz nincs beállítva:
|
||||
{{range .Backup.UnconfiguredApps}}
|
||||
<a href="/stacks/{{.StackName}}/deploy" style="color:var(--accent-blue)">{{.DisplayName}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="cross-drive-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="triggerAllCrossDrive(this)">Összes futtatása most</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Section 5: Snapshots -->
|
||||
<div class="backup-section-card">
|
||||
<h3>Pillanatképek</h3>
|
||||
@@ -432,6 +483,28 @@
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
function triggerAllCrossDrive(btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Indítás...';
|
||||
fetch('/api/backup/cross-drive/run-all', {method: 'POST'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d.ok) {
|
||||
alert('Hiba: ' + (d.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Összes futtatása most';
|
||||
return;
|
||||
}
|
||||
btn.textContent = '⏳ Mentések futnak...';
|
||||
setTimeout(function() { location.reload(); }, 5000);
|
||||
})
|
||||
.catch(function(e) {
|
||||
alert('Hálózati hiba: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Összes futtatása most';
|
||||
});
|
||||
}
|
||||
|
||||
function triggerBackupFromPage() {
|
||||
const btn = document.getElementById('backup-page-btn');
|
||||
btn.disabled = true;
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
</div>
|
||||
|
||||
<div class="deploy-container">
|
||||
{{if .FlashSuccess}}<div class="flash flash-success">{{.FlashSuccess}}</div>{{end}}
|
||||
{{if .FlashError}}<div class="flash flash-error">{{.FlashError}}</div>{{end}}
|
||||
<div class="deploy-info">
|
||||
<img class="deploy-logo" src="{{.LogoURL}}" alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{.LogoPNGURL}}'">
|
||||
<div>
|
||||
@@ -90,6 +92,115 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .AlreadyDeployed}}
|
||||
{{if .StorageInfo}}
|
||||
<div class="deploy-cross-drive">
|
||||
<h4>🔒 Biztonsági mentés</h4>
|
||||
|
||||
<div class="cross-drive-nightly">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="app-backup-enabled" {{if .AppBackupEnabled}}checked{{end}} disabled>
|
||||
<span class="toggle-label">Napi mentésbe foglalás (restic, helyi)</span>
|
||||
</label>
|
||||
<span class="form-hint" style="display:block;margin-top:.25rem">
|
||||
Az alkalmazás adatai bekerülnek az éjszakai biztonsági mentésbe.
|
||||
<a href="/backups" style="color:var(--accent-blue)">Beállítás a mentési oldalon</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr style="border-color:var(--border);margin:1rem 0">
|
||||
|
||||
<p style="font-weight:500;margin-bottom:1rem">Másolat másik meghajtóra:</p>
|
||||
|
||||
{{if .BackupDestWarning}}
|
||||
<div class="alert alert-warning" style="margin-bottom:1rem">⚠️ {{.BackupDestWarning}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if not .BackupDestPaths}}
|
||||
<div class="alert alert-info">
|
||||
Másik adattároló szükséges a másolat készítéséhez.
|
||||
<a href="/settings" style="color:var(--accent-blue)">Csatlakoztass egy külső meghajtót a Beállítások oldalon.</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<form method="post" action="/settings/cross-backup/{{.Meta.Slug}}">
|
||||
<div class="settings-grid" style="margin-bottom:1rem">
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Engedélyezve</span>
|
||||
<label class="toggle" style="margin:0">
|
||||
<input type="checkbox" name="cross_drive_enabled" id="cross-drive-enabled"
|
||||
{{if and .CrossDriveConfig .CrossDriveConfig.Enabled}}checked{{end}}>
|
||||
<span class="toggle-label">Igen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Cél tárhely</span>
|
||||
<select name="cross_drive_dest" class="form-control" style="max-width:20rem">
|
||||
{{range .BackupDestPaths}}
|
||||
<option value="{{.Path}}"
|
||||
{{if and $.CrossDriveConfig (eq $.CrossDriveConfig.DestinationPath .Path)}}selected{{end}}>
|
||||
{{.Label}} ({{.Path}}){{if .IsDefault}} ★{{end}}
|
||||
{{if .FreeHuman}} — {{.FreeHuman}} szabad{{end}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Módszer</span>
|
||||
<select name="cross_drive_method" class="form-control" style="max-width:20rem">
|
||||
<option value="rsync" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Method "rsync")}}selected{{end}}>
|
||||
Egyszerű másolat (rsync)
|
||||
</option>
|
||||
<option value="restic" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Method "restic")}}selected{{end}}>
|
||||
Verziózott mentés (restic)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Ütemezés</span>
|
||||
<select name="cross_drive_schedule" class="form-control" style="max-width:20rem">
|
||||
<option value="daily" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "daily")}}selected{{end}}>
|
||||
Naponta (03:30)
|
||||
</option>
|
||||
<option value="weekly" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "weekly")}}selected{{end}}>
|
||||
Hetente (vasárnap 04:30)
|
||||
</option>
|
||||
<option value="manual" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "manual")}}selected{{end}}>
|
||||
Csak kézi indítás
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .CrossDriveConfig}}
|
||||
{{if .CrossDriveConfig.LastRun}}
|
||||
<div class="form-hint" style="margin-bottom:.75rem">
|
||||
Utolsó futás: {{.CrossDriveConfig.LastRun}}
|
||||
{{if eq .CrossDriveConfig.LastStatus "ok"}}✅ Sikeres{{else if eq .CrossDriveConfig.LastStatus "error"}}❌ Hiba: {{.CrossDriveConfig.LastError}}{{else if eq .CrossDriveConfig.LastStatus "running"}}⏳ Fut...{{end}}
|
||||
{{if .CrossDriveConfig.LastDuration}} ({{.CrossDriveConfig.LastDuration}}){{end}}
|
||||
{{if .CrossDriveConfig.LastSizeHuman}} — {{.CrossDriveConfig.LastSizeHuman}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Beállítások mentése</button>
|
||||
{{if and .CrossDriveConfig .CrossDriveConfig.Enabled}}
|
||||
<button type="button" class="btn btn-sm btn-outline"
|
||||
onclick="triggerCrossDriveBackup('{{.Meta.Slug}}', this)">
|
||||
Mentés most
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="form-hint" style="margin-top:.75rem;color:var(--text-muted)">
|
||||
⚠️ A cél meghajtó legyen más fizikai eszköz, mint az alkalmazás adattárolója.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if and (not .AlreadyDeployed) .MemoryInfo}}
|
||||
{{with .MemoryInfo}}
|
||||
{{if .Available}}
|
||||
@@ -242,6 +353,46 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function triggerCrossDriveBackup(stackName, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Mentés folyamatban...';
|
||||
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST'})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d.ok) {
|
||||
alert('Hiba: ' + (d.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Mentés most';
|
||||
return;
|
||||
}
|
||||
btn.textContent = '⏳ Mentés folyamatban...';
|
||||
// Poll status
|
||||
var poll = setInterval(function() {
|
||||
fetch('/api/stacks/' + stackName + '/cross-backup/status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(s) {
|
||||
if (!s.ok || !s.data) return;
|
||||
if (!s.data.running) {
|
||||
clearInterval(poll);
|
||||
var status = s.data.last_status;
|
||||
if (status === 'ok') {
|
||||
btn.textContent = '✅ Mentés kész';
|
||||
} else {
|
||||
btn.textContent = '❌ Hiba';
|
||||
alert('Hiba: ' + (s.data.last_error || 'Ismeretlen hiba'));
|
||||
}
|
||||
setTimeout(function() { location.reload(); }, 2000);
|
||||
}
|
||||
}).catch(function(){});
|
||||
}, 3000);
|
||||
})
|
||||
.catch(function(e) {
|
||||
alert('Hálózati hiba: ' + e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Mentés most';
|
||||
});
|
||||
}
|
||||
|
||||
function checkStorageSpace(sel) {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
var warn = document.getElementById('storage-space-warn');
|
||||
|
||||
@@ -2238,6 +2238,7 @@ a.stat-card:hover {
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.deploy-stale-data h4 {
|
||||
@@ -2265,3 +2266,67 @@ a.stat-card:hover {
|
||||
.btn-danger:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Cross-drive backup card on deploy page */
|
||||
.deploy-cross-drive {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.deploy-cross-drive h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cross-drive-nightly {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
/* Cross-drive list on backup page */
|
||||
.cross-drive-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.cross-drive-item {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
|
||||
.cross-drive-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.cross-drive-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cross-drive-name:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.cross-drive-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cross-drive-actions {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user