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