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:
2026-02-17 15:45:31 +01:00
parent d4da3e6ea2
commit 1a8036d055
12 changed files with 1126 additions and 44 deletions
+11
View File
@@ -1,5 +1,16 @@
## Changelog ## Changelog
### What was just completed (2026-02-17 session 35)
- **v0.11.8 — Per-App Cross-Drive Backup (3-2-1 rule, second copy on different media):**
- **Feature: CrossDriveBackup data model** — `AppBackupPrefs` extended with `CrossDrive *CrossDriveBackup` field in `settings.go`. New methods: `GetCrossDriveConfig`, `SetCrossDriveConfig`, `UpdateCrossDriveStatus`, `GetAllCrossDriveConfigs`, `GetOrCreateCrossDrivePassword`. Existing `SetAppBackup`/`SetAppBackupBulk` now preserve cross-drive config. Auto-generated restic password stored in `settings.json`.
- **Feature: CrossDriveRunner** — New `internal/backup/crossdrive.go`. Supports rsync (simple mirror with `--delete`) and restic (versioned, deduplicated, shared repo). Safety guards: destination ≠ source, mount point check, writable check, per-app concurrency lock. `RunAllScheduled(ctx, schedule)` iterates all apps matching the given schedule. Status (last_run, last_status, last_error, last_duration, last_size_human) persisted to settings.json after each run.
- **Feature: Scheduler jobs** — Two new daily jobs: `cross-drive-daily` at 03:30 (for apps with `schedule: daily`), `cross-drive-weekly` at 04:30 Sundays only (for `schedule: weekly`).
- **Feature: API endpoints** — 4 new routes: `POST /api/stacks/{name}/cross-backup`, `POST /api/stacks/{name}/cross-backup/run`, `GET /api/stacks/{name}/cross-backup/status`, `POST /api/backup/cross-drive/run-all`.
- **Feature: Deploy/Settings page UI** — New "Biztonsági mentés" card on the deploy page for apps with HDD data. Shows nightly backup toggle (read-only link), cross-drive dropdowns (destination, method, schedule), last run status, manual trigger button. States: no other storage (info message), configured, destination unreachable (warning). Flash messages on save redirect.
- **Feature: Backup page summary** — New "Másolatok másik meghajtóra" section showing all configured apps with method, destination, last status, size. Warns about unconfigured apps with HDD data. Destination health warnings. "Összes futtatása most" button.
- **CSS:** `margin-bottom: 1.5rem` added to `.deploy-stale-data`. New styles: `.deploy-cross-drive`, `.cross-drive-list`, `.cross-drive-item`, `.cross-drive-header`, `.cross-drive-meta`, `.cross-drive-actions`.
- **Files modified (10):** `settings/settings.go`, `backup/crossdrive.go` (new), `backup/backup.go`, `api/router.go`, `web/handlers.go`, `web/server.go`, `web/templates/deploy.html`, `web/templates/backups.html`, `web/templates/style.css`, `cmd/controller/main.go`
### What was just completed (2026-02-17 session 34) ### What was just completed (2026-02-17 session 34)
- **v0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Title Fix:** - **v0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Title Fix:**
- **Feature: Stale data cleanup** — After app data migration, the deploy/settings page now shows leftover data on previous storage paths with size info and a delete button. Two-step confirmation required before deletion. Protected paths (storage root, media, Dokumentumok, appdata) cannot be deleted. Also available immediately after migration on the migration-done page. - **Feature: Stale data cleanup** — After app data migration, the deploy/settings page now shows leftover data on previous storage paths with size info and a delete button. Two-step confirmation required before deletion. Protected paths (storage root, media, Dokumentumok, appdata) cannot be deleted. Also available immediately after migration on the migration-done page.
+4 -2
View File
@@ -7,7 +7,7 @@
> >
> Ask Claude Code: "Please update CONTEXT.md with what we did today" > Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-02-17 (session 33) Last updated: 2026-02-17 (session 35)
--- ---
@@ -20,7 +20,7 @@ Last updated: 2026-02-17 (session 33)
- Customer deployments use Docker Compose (not Kubernetes) for simplicity - Customer deployments use Docker Compose (not Kubernetes) for simplicity
### felhom-controller (this repo) ### felhom-controller (this repo)
- **Version:** v0.11.6 - **Version:** v0.11.8
- **Phase 1:** ✅ COMPLETE — Stack Manager + Deploy Flow - **Phase 1:** ✅ COMPLETE — Stack Manager + Deploy Flow
- **Phase 2:** ✅ COMPLETE — Monitoring & Health (scheduler, CPU/temp, healthchecks.io pings) - **Phase 2:** ✅ COMPLETE — Monitoring & Health (scheduler, CPU/temp, healthchecks.io pings)
- **Phase 3:** ✅ COMPLETE — Backups (DB dumps, restic integration, manual trigger, **dedicated backup page**) - **Phase 3:** ✅ COMPLETE — Backups (DB dumps, restic integration, manual trigger, **dedicated backup page**)
@@ -36,6 +36,8 @@ Last updated: 2026-02-17 (session 33)
- **v0.11.3 bugfix:** ✅ COMPLETE — Added `fdisk` package to Dockerfile (provides `sfdisk`; not in `util-linux` on Debian bookworm) - **v0.11.3 bugfix:** ✅ COMPLETE — Added `fdisk` package to Dockerfile (provides `sfdisk`; not in `util-linux` on Debian bookworm)
- **v0.11.4 bugfix:** ✅ COMPLETE — FormatAndMount: fixed sfdisk (wipefs+force+`,,`), mount (explicit device path), mount propagation (rshared), ASCII label, smart partition skip, findmnt verification - **v0.11.4 bugfix:** ✅ COMPLETE — FormatAndMount: fixed sfdisk (wipefs+force+`,,`), mount (explicit device path), mount propagation (rshared), ASCII label, smart partition skip, findmnt verification
- **v0.11.6:** ✅ COMPLETE — FileBrowser auto-mount sync (`syncFileBrowserMounts()`) + 3 UI fixes (badge color, progress bar, button text) - **v0.11.6:** ✅ COMPLETE — FileBrowser auto-mount sync (`syncFileBrowserMounts()`) + 3 UI fixes (badge color, progress bar, button text)
- **v0.11.7:** ✅ COMPLETE — Stale data cleanup + FileBrowser sync after migration + deploy page title fix
- **v0.11.8:** ✅ COMPLETE — Per-App Cross-Drive Backup (3-2-1 rule): rsync/restic to secondary drive, deploy page UI, backup page summary, scheduler jobs, API endpoints
- **First app deployed:** Paperless-ngx on demo-felhom.eu (2026-02-13) - **First app deployed:** Paperless-ngx on demo-felhom.eu (2026-02-13)
- **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080 - **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080
- **All Phase 1-5 features working:** deploy, start/stop/restart/update, logs, health-aware states, auth, monitoring, backups, backup detail page, system monitoring page, settings page - **All Phase 1-5 features working:** deploy, start/stop/restart/update, logs, health-aware states, auth, monitoring, backups, backup detail page, system monitoring page, settings page
+22 -6
View File
@@ -116,12 +116,13 @@ func main() {
// --- Initialize backup manager --- // --- Initialize backup manager ---
var backupMgr *backup.Manager var backupMgr *backup.Manager
stackProv := &stackAdapter{
mgr: stackMgr,
getStoragePaths: func() []settings.StoragePath { return sett.GetStoragePaths() },
}
if cfg.Backup.Enabled { if cfg.Backup.Enabled {
backupMgr = backup.NewManager(cfg, pinger, sett, logger) backupMgr = backup.NewManager(cfg, pinger, sett, logger)
backupMgr.SetStackProvider(&stackAdapter{ backupMgr.SetStackProvider(stackProv)
mgr: stackMgr,
getStoragePaths: func() []settings.StoragePath { return sett.GetStoragePaths() },
})
backupMgr.AfterBackup = func() { backupMgr.AfterBackup = func() {
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule) nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule) nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
@@ -130,6 +131,9 @@ func main() {
go backupMgr.LoadSnapshotHistory() go backupMgr.LoadSnapshotHistory()
} }
// --- Initialize cross-drive backup runner ---
crossDriveRunner := backup.NewCrossDriveRunner(sett, stackProv, logger)
// --- Initialize alert manager --- // --- Initialize alert manager ---
alertMgr := web.NewAlertManager(logger) alertMgr := web.NewAlertManager(logger)
@@ -212,6 +216,18 @@ func main() {
}) })
} }
// Cross-drive backup — daily at 03:30 (after main backup at 03:00)
sched.Daily("cross-drive-daily", "03:30", func(ctx context.Context) error {
return crossDriveRunner.RunAllScheduled(ctx, "daily")
})
// Cross-drive weekly — Sunday 04:30 (after integrity check at 04:00)
sched.Daily("cross-drive-weekly", "04:30", func(ctx context.Context) error {
if time.Now().Weekday() != time.Sunday {
return nil
}
return crossDriveRunner.RunAllScheduled(ctx, "weekly")
})
// Metrics prune — daily at 04:00 // Metrics prune — daily at 04:00
if metricsStore != nil { if metricsStore != nil {
sched.Daily("metrics-prune", "04:00", func(ctx context.Context) error { sched.Daily("metrics-prune", "04:00", func(ctx context.Context) error {
@@ -300,10 +316,10 @@ func main() {
}() }()
// --- Initialize API router --- // --- Initialize API router ---
apiRouter := api.NewRouter(cfg, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, logger) apiRouter := api.NewRouter(cfg, sett, stackMgr, syncer, cpuCollector, backupMgr, crossDriveRunner, metricsStore, logger)
// --- Initialize web server --- // --- Initialize web server ---
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, alertMgr, notifier, logger, Version) webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, logger, Version)
// --- Build HTTP mux --- // --- Build HTTP mux ---
mux := http.NewServeMux() mux := http.NewServeMux()
+151 -9
View File
@@ -13,6 +13,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics" "gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync" catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/system" "gitea.dooplex.hu/admin/felhom-controller/internal/system"
@@ -20,17 +21,19 @@ import (
// Router handles all /api/* requests. // Router handles all /api/* requests.
type Router struct { type Router struct {
cfg *config.Config cfg *config.Config
stackMgr *stacks.Manager sett *settings.Settings
syncer *catalogsync.Syncer stackMgr *stacks.Manager
cpuCollector *system.CPUCollector syncer *catalogsync.Syncer
backupMgr *backup.Manager cpuCollector *system.CPUCollector
metricsStore *metrics.MetricsStore backupMgr *backup.Manager
logger *log.Logger 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 { 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, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, logger: logger} return &Router{cfg: cfg, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, logger: logger}
} }
type apiResponse struct { 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/"): case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
r.deleteStack(w, req, trimSegment(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 // POST /api/sync — trigger immediate catalog sync
case path == "/sync" && req.Method == http.MethodPost: case path == "/sync" && req.Method == http.MethodPost:
r.triggerSync(w, req) 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}) 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. // parseTimeRange reads range or from/to query params.
func parseTimeRange(req *http.Request) (from, to time.Time) { func parseTimeRange(req *http.Request) (from, to time.Time) {
to = time.Now() to = time.Now()
+20
View File
@@ -53,6 +53,21 @@ type SnapshotRecord struct {
HasStats bool `json:"has_stats"` // false for historical entries loaded from restic 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. // FullBackupStatus contains everything the backup page needs.
type FullBackupStatus struct { type FullBackupStatus struct {
Enabled bool Enabled bool
@@ -88,6 +103,11 @@ type FullBackupStatus struct {
// App data backup // App data backup
AppDataInfo []AppBackupInfo 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) // Flash messages (set by handlers, passed through redirect)
FlashSuccess string FlashSuccess string
FlashError string FlashError string
+308
View File
@@ -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
}
+128 -3
View File
@@ -1,8 +1,10 @@
package settings package settings
import ( import (
"crypto/rand"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -11,6 +13,9 @@ import (
"time" "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. // Settings holds customer-modifiable overrides and cached state.
// Persisted as a single JSON file (settings.json) in the data directory. // Persisted as a single JSON file (settings.json) in the data directory.
type Settings struct { type Settings struct {
@@ -32,11 +37,33 @@ type Settings struct {
// Storage paths registry // Storage paths registry
StoragePaths []StoragePath `json:"storage_paths,omitempty"` 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. // AppBackupPrefs holds per-app backup toggle state.
type AppBackupPrefs struct { type AppBackupPrefs struct {
// Existing: includes app data in nightly restic (same drive)
Enabled bool `json:"enabled"` 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. // 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. // 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 { func (s *Settings) SetAppBackup(stackName string, enabled bool) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.AppBackup == nil { if s.AppBackup == nil {
s.AppBackup = make(map[string]AppBackupPrefs) 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() 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. // 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 { func (s *Settings) SetAppBackupBulk(prefs map[string]bool) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.AppBackup = make(map[string]AppBackupPrefs, len(prefs)) newMap := make(map[string]AppBackupPrefs, len(prefs))
for name, enabled := range 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() 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 --- // --- Storage Paths ---
// GetStoragePaths returns a copy of all registered storage paths. // GetStoragePaths returns a copy of all registered storage paths.
+164
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
@@ -17,6 +18,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// DeployStoragePath extends StoragePath with free space data for the deploy dropdown. // DeployStoragePath extends StoragePath with free space data for the deploy dropdown.
type DeployStoragePath struct { type DeployStoragePath struct {
settings.StoragePath settings.StoragePath
@@ -204,6 +206,45 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
if len(staleData) > 0 { if len(staleData) > 0 {
data["StaleData"] = staleData 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) // 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 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) 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 data["Backup"] = fullStatus
// Restic password for display // 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) 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) { func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm() _ = r.ParseForm()
+29 -24
View File
@@ -20,17 +20,18 @@ import (
) )
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
stackMgr *stacks.Manager stackMgr *stacks.Manager
cpuCollector *system.CPUCollector cpuCollector *system.CPUCollector
backupMgr *backup.Manager backupMgr *backup.Manager
scheduler *scheduler.Scheduler crossDriveRunner *backup.CrossDriveRunner
settings *settings.Settings scheduler *scheduler.Scheduler
alertManager *AlertManager settings *settings.Settings
notifier *notify.Notifier alertManager *AlertManager
logger *log.Logger notifier *notify.Notifier
version string logger *log.Logger
tmpl *template.Template version string
tmpl *template.Template
sessions map[string]*session sessions map[string]*session
sessionsMu sync.RWMutex sessionsMu sync.RWMutex
@@ -41,20 +42,21 @@ type Server struct {
diskJob *activeDiskJob 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{ s := &Server{
cfg: cfg, cfg: cfg,
stackMgr: stackMgr, stackMgr: stackMgr,
cpuCollector: cpuCollector, cpuCollector: cpuCollector,
backupMgr: backupMgr, backupMgr: backupMgr,
scheduler: sched, crossDriveRunner: crossDrive,
settings: sett, scheduler: sched,
alertManager: alertMgr, settings: sett,
notifier: notif, alertManager: alertMgr,
logger: logger, notifier: notif,
version: version, logger: logger,
sessions: make(map[string]*session), version: version,
done: make(chan struct{}), sessions: make(map[string]*session),
done: make(chan struct{}),
} }
s.loadTemplates() s.loadTemplates()
go s.cleanupSessions() go s.cleanupSessions()
@@ -110,6 +112,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.settingsStorageLabelHandler(w, r) s.settingsStorageLabelHandler(w, r)
case path == "/settings/app-backup" && r.Method == http.MethodPost: case path == "/settings/app-backup" && r.Method == http.MethodPost:
s.settingsAppBackupHandler(w, r) 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: case path == "/backup/restore" && r.Method == http.MethodPost:
s.backupRestoreHandler(w, r) s.backupRestoreHandler(w, r)
case path == "/settings/storage/init": case path == "/settings/storage/init":
@@ -283,6 +283,57 @@
</div> </div>
{{end}} {{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 --> <!-- Section 5: Snapshots -->
<div class="backup-section-card"> <div class="backup-section-card">
<h3>Pillanatképek</h3> <h3>Pillanatképek</h3>
@@ -432,6 +483,28 @@
{{end}} {{end}}
<script> <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() { function triggerBackupFromPage() {
const btn = document.getElementById('backup-page-btn'); const btn = document.getElementById('backup-page-btn');
btn.disabled = true; btn.disabled = true;
@@ -10,6 +10,8 @@
</div> </div>
<div class="deploy-container"> <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"> <div class="deploy-info">
<img class="deploy-logo" src="{{.LogoURL}}" alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{.LogoPNGURL}}'"> <img class="deploy-logo" src="{{.LogoURL}}" alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{.LogoPNGURL}}'">
<div> <div>
@@ -90,6 +92,115 @@
{{end}} {{end}}
{{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}} {{if and (not .AlreadyDeployed) .MemoryInfo}}
{{with .MemoryInfo}} {{with .MemoryInfo}}
{{if .Available}} {{if .Available}}
@@ -242,6 +353,46 @@
</div> </div>
<script> <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) { function checkStorageSpace(sel) {
var opt = sel.options[sel.selectedIndex]; var opt = sel.options[sel.selectedIndex];
var warn = document.getElementById('storage-space-warn'); var warn = document.getElementById('storage-space-warn');
@@ -2238,6 +2238,7 @@ a.stat-card:hover {
border-radius: var(--radius); border-radius: var(--radius);
padding: 1.5rem; padding: 1.5rem;
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 1.5rem;
} }
.deploy-stale-data h4 { .deploy-stale-data h4 {
@@ -2265,3 +2266,67 @@ a.stat-card:hover {
.btn-danger:hover { .btn-danger:hover {
opacity: 0.85; 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;
}