Phase 3 complete: per-app backup toggles, restore, storage overview
- Storage overview on backup page (SSD/HDD bars, repo stats) - Restic password visibility + hub sync for disaster recovery - App data discovery (HDD bind mounts, Docker volumes) - Per-app backup toggle checkboxes with settings persistence - Dynamic backup paths: enabled app HDD data included in restic snapshots - Limited app restore from snapshots (self-service recovery) - Snapshots API endpoint for restore dropdown - Version bump to 0.8.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -109,6 +109,7 @@ func main() {
|
||||
var backupMgr *backup.Manager
|
||||
if cfg.Backup.Enabled {
|
||||
backupMgr = backup.NewManager(cfg, pinger, sett, logger)
|
||||
backupMgr.SetStackProvider(&stackAdapter{mgr: stackMgr, hddPath: cfg.Paths.HDDPath})
|
||||
backupMgr.AfterBackup = func() {
|
||||
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
|
||||
nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
|
||||
@@ -314,3 +315,41 @@ func setupLogger(cfg *config.Config) *log.Logger {
|
||||
|
||||
return logger
|
||||
}
|
||||
|
||||
// stackAdapter implements backup.StackDataProvider using stacks.Manager.
|
||||
type stackAdapter struct {
|
||||
mgr *stacks.Manager
|
||||
hddPath string
|
||||
}
|
||||
|
||||
func (a *stackAdapter) GetStackComposePath(name string) (string, bool) {
|
||||
s, ok := a.mgr.GetStack(name)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return s.ComposePath, true
|
||||
}
|
||||
|
||||
func (a *stackAdapter) ListDeployedStacks() []backup.StackSummary {
|
||||
var result []backup.StackSummary
|
||||
for _, s := range a.mgr.GetStacks() {
|
||||
if !s.Deployed {
|
||||
continue
|
||||
}
|
||||
result = append(result, backup.StackSummary{
|
||||
Name: s.Name,
|
||||
DisplayName: s.Meta.DisplayName,
|
||||
ComposePath: s.ComposePath,
|
||||
NeedsHDD: s.Meta.Resources.NeedsHDD,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *stackAdapter) GetStackHDDMounts(name string) []string {
|
||||
s, ok := a.mgr.GetStack(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return stacks.ParseComposeHDDMounts(s.ComposePath, a.hddPath)
|
||||
}
|
||||
|
||||
@@ -114,6 +114,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
case path == "/backup/run" && req.Method == http.MethodPost:
|
||||
r.triggerBackup(w, req)
|
||||
|
||||
// GET /api/backup/snapshots
|
||||
case path == "/backup/snapshots" && req.Method == http.MethodGet:
|
||||
r.backupSnapshots(w, req)
|
||||
|
||||
// GET /api/metrics/system
|
||||
case path == "/metrics/system" && req.Method == http.MethodGet:
|
||||
r.metricsSystem(w, req)
|
||||
@@ -422,6 +426,23 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
|
||||
}
|
||||
|
||||
func (r *Router) backupSnapshots(w http.ResponseWriter, _ *http.Request) {
|
||||
if r.backupMgr == nil {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
snapshots, err := r.backupMgr.ListSnapshots(50)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if snapshots == nil {
|
||||
snapshots = []backup.SnapshotInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots})
|
||||
}
|
||||
|
||||
// --- Metrics handlers ---
|
||||
|
||||
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// StackDataProvider provides stack data to the backup package without circular imports.
|
||||
type StackDataProvider interface {
|
||||
GetStackComposePath(name string) (composePath string, ok bool)
|
||||
ListDeployedStacks() []StackSummary
|
||||
GetStackHDDMounts(name string) []string
|
||||
}
|
||||
|
||||
// StackSummary holds minimal stack info needed for app data discovery.
|
||||
type StackSummary struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
ComposePath string
|
||||
NeedsHDD bool
|
||||
}
|
||||
|
||||
// AppBackupInfo holds backup-relevant data paths for a deployed app.
|
||||
type AppBackupInfo struct {
|
||||
StackName string
|
||||
DisplayName string
|
||||
NeedsHDD bool
|
||||
HDDPaths []AppDataPath
|
||||
HDDTotalSize int64
|
||||
HDDSizeHuman string
|
||||
DockerVolumes []AppDockerVolume
|
||||
BackupEnabled bool
|
||||
HasHDDData bool
|
||||
HasDBDump bool
|
||||
}
|
||||
|
||||
// AppDataPath represents a single HDD bind mount path.
|
||||
type AppDataPath struct {
|
||||
HostPath string
|
||||
Exists bool
|
||||
SizeHuman string
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
// AppDockerVolume represents a named Docker volume.
|
||||
type AppDockerVolume struct {
|
||||
Name string
|
||||
Contains string
|
||||
}
|
||||
|
||||
// DiscoverAppData discovers backup-relevant data for all deployed apps.
|
||||
func DiscoverAppData(provider StackDataProvider, hddPath string, backupPrefs map[string]bool, discoveredDBs []DiscoveredDB) []AppBackupInfo {
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []AppBackupInfo
|
||||
|
||||
for _, stack := range provider.ListDeployedStacks() {
|
||||
info := AppBackupInfo{
|
||||
StackName: stack.Name,
|
||||
DisplayName: stack.DisplayName,
|
||||
NeedsHDD: stack.NeedsHDD,
|
||||
}
|
||||
|
||||
// Discover HDD bind mounts via adapter
|
||||
hddMounts := provider.GetStackHDDMounts(stack.Name)
|
||||
for _, mount := range hddMounts {
|
||||
path := AppDataPath{HostPath: mount}
|
||||
if fi, err := os.Stat(mount); err == nil && fi.IsDir() {
|
||||
path.Exists = true
|
||||
path.SizeBytes = appDirSizeBytes(mount)
|
||||
path.SizeHuman = appDirSizeHuman(mount)
|
||||
}
|
||||
info.HDDPaths = append(info.HDDPaths, path)
|
||||
info.HDDTotalSize += path.SizeBytes
|
||||
}
|
||||
info.HDDSizeHuman = humanizeBytes(info.HDDTotalSize)
|
||||
info.HasHDDData = len(info.HDDPaths) > 0
|
||||
|
||||
// Discover Docker named volumes from compose
|
||||
info.DockerVolumes = parseComposeNamedVolumes(stack.ComposePath)
|
||||
|
||||
// Check if app has a DB container (already backed up via DB dump)
|
||||
for _, db := range discoveredDBs {
|
||||
if db.StackName == stack.Name {
|
||||
info.HasDBDump = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
info.BackupEnabled = backupPrefs[stack.Name]
|
||||
|
||||
// Only include apps that have some data to show
|
||||
if info.HasHDDData || len(info.DockerVolumes) > 0 {
|
||||
result = append(result, info)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml.
|
||||
func parseComposeNamedVolumes(composePath string) []AppDockerVolume {
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var compose struct {
|
||||
Volumes map[string]interface{} `yaml:"volumes"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var volumes []AppDockerVolume
|
||||
for name, cfg := range compose.Volumes {
|
||||
// Skip external volumes
|
||||
if cfgMap, ok := cfg.(map[string]interface{}); ok {
|
||||
if ext, ok := cfgMap["external"]; ok && ext == true {
|
||||
continue
|
||||
}
|
||||
}
|
||||
volumes = append(volumes, AppDockerVolume{Name: name})
|
||||
}
|
||||
return volumes
|
||||
}
|
||||
|
||||
// appDirSizeHuman returns a human-readable size string for a directory using du.
|
||||
func appDirSizeHuman(path string) string {
|
||||
cmd := exec.Command("du", "-sh", path)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "?"
|
||||
}
|
||||
fields := strings.Fields(string(output))
|
||||
if len(fields) > 0 {
|
||||
return fields[0]
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// appDirSizeBytes returns the total size in bytes for a directory.
|
||||
func appDirSizeBytes(path string) int64 {
|
||||
cmd := exec.Command("du", "-sb", path)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(output))
|
||||
if len(fields) > 0 {
|
||||
var size int64
|
||||
fmt.Sscanf(fields[0], "%d", &size)
|
||||
return size
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// humanizeBytes converts bytes to a human-readable string.
|
||||
func humanizeBytes(b int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
GB = MB * 1024
|
||||
)
|
||||
switch {
|
||||
case b >= GB:
|
||||
return fmt.Sprintf("%.1f GB", float64(b)/float64(GB))
|
||||
case b >= MB:
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/float64(MB))
|
||||
case b >= KB:
|
||||
return fmt.Sprintf("%.1f KB", float64(b)/float64(KB))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -16,11 +17,12 @@ import (
|
||||
|
||||
// Manager orchestrates database dumps and restic backups.
|
||||
type Manager struct {
|
||||
cfg *config.Config
|
||||
restic *ResticManager
|
||||
logger *log.Logger
|
||||
pinger *monitor.Pinger
|
||||
settings *settings.Settings
|
||||
cfg *config.Config
|
||||
restic *ResticManager
|
||||
logger *log.Logger
|
||||
pinger *monitor.Pinger
|
||||
settings *settings.Settings
|
||||
stackProvider StackDataProvider
|
||||
|
||||
mu sync.Mutex
|
||||
lastDBDump *DBDumpStatus
|
||||
@@ -82,6 +84,13 @@ type FullBackupStatus struct {
|
||||
|
||||
// Remote (placeholder)
|
||||
RemoteEnabled bool
|
||||
|
||||
// App data backup
|
||||
AppDataInfo []AppBackupInfo
|
||||
|
||||
// Flash messages (set by handlers, passed through redirect)
|
||||
FlashSuccess string
|
||||
FlashError string
|
||||
}
|
||||
|
||||
// DBDumpStatus holds the last DB dump result.
|
||||
@@ -209,12 +218,17 @@ func (m *Manager) RunBackup(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Backup paths
|
||||
// Backup paths: base + dynamic app data
|
||||
paths := []string{
|
||||
m.cfg.Paths.StacksDir,
|
||||
m.cfg.Paths.DBDumpDir,
|
||||
"/opt/docker/felhom-controller/controller.yaml",
|
||||
}
|
||||
appPaths := m.resolveAppBackupPaths()
|
||||
if len(appPaths) > 0 {
|
||||
paths = append(paths, appPaths...)
|
||||
m.logger.Printf("[INFO] Backup paths (%d total, %d app data): %v", len(paths), len(appPaths), paths)
|
||||
}
|
||||
tags := []string{"felhom", m.cfg.Customer.ID}
|
||||
|
||||
result, err := m.restic.Snapshot(paths, tags)
|
||||
@@ -358,13 +372,60 @@ func (m *Manager) GetRepoStats() (*RepoStats, error) {
|
||||
return m.restic.Stats()
|
||||
}
|
||||
|
||||
// IsRunning returns whether a backup is currently in progress.
|
||||
// IsRunning returns whether a backup or restore is currently in progress.
|
||||
func (m *Manager) IsRunning() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.running
|
||||
}
|
||||
|
||||
// GetResticPassword returns the restic repository encryption password.
|
||||
func (m *Manager) GetResticPassword() (string, error) {
|
||||
return m.restic.GetPassword()
|
||||
}
|
||||
|
||||
// ListSnapshots returns snapshots from the restic repository.
|
||||
func (m *Manager) ListSnapshots(limit int) ([]SnapshotInfo, error) {
|
||||
return m.restic.ListSnapshots(limit)
|
||||
}
|
||||
|
||||
// SetStackProvider sets the stack data provider for app data discovery.
|
||||
func (m *Manager) SetStackProvider(provider StackDataProvider) {
|
||||
m.stackProvider = provider
|
||||
}
|
||||
|
||||
// resolveAppBackupPaths returns HDD paths for all enabled app backups.
|
||||
func (m *Manager) resolveAppBackupPaths() []string {
|
||||
if m.stackProvider == nil || m.settings == nil {
|
||||
return nil
|
||||
}
|
||||
appBackupMap := m.settings.GetAppBackupMap()
|
||||
if len(appBackupMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var paths []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for stackName, enabled := range appBackupMap {
|
||||
if !enabled {
|
||||
continue
|
||||
}
|
||||
hddMounts := m.stackProvider.GetStackHDDMounts(stackName)
|
||||
for _, mount := range hddMounts {
|
||||
if seen[mount] {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(mount); err == nil {
|
||||
paths = append(paths, mount)
|
||||
seen[mount] = true
|
||||
m.logger.Printf("[DEBUG] Including app data: %s (from %s)", mount, stackName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func shouldPrune(schedule string) bool {
|
||||
loc, err := time.LoadLocation("Europe/Budapest")
|
||||
if err != nil {
|
||||
@@ -450,6 +511,18 @@ func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
|
||||
status.DiscoveredDBs = dbs
|
||||
}
|
||||
|
||||
// Discover app data (for per-app backup toggles)
|
||||
if m.stackProvider != nil {
|
||||
backupPrefs := m.settings.GetAppBackupMap()
|
||||
status.AppDataInfo = DiscoverAppData(m.stackProvider, m.cfg.Paths.HDDPath, backupPrefs, status.DiscoveredDBs)
|
||||
|
||||
// Include enabled app backup paths in the displayed BackupPaths
|
||||
appPaths := m.resolveAppBackupPaths()
|
||||
if len(appPaths) > 0 {
|
||||
status.BackupPaths = append(status.BackupPaths, appPaths...)
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-check: if LastDBDump results have empty validation but files exist,
|
||||
// re-validate from disk. This handles controller restarts and race conditions.
|
||||
if m.lastDBDump != nil && filesErr == nil {
|
||||
|
||||
@@ -303,6 +303,41 @@ func (r *ResticManager) Stats() (*RepoStats, error) {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetPassword reads and returns the restic repository password.
|
||||
func (r *ResticManager) GetPassword() (string, error) {
|
||||
data, err := os.ReadFile(r.passwordFile)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading restic password: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// RestoreAppData restores specific paths from a restic snapshot.
|
||||
func (r *ResticManager) RestoreAppData(snapshotID string, paths []string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"restore", snapshotID,
|
||||
"--target", "/",
|
||||
}
|
||||
for _, p := range paths {
|
||||
args = append(args, "--include", p)
|
||||
}
|
||||
|
||||
r.logger.Printf("[WARN] RESTORE started: snapshot=%s, paths=%v", snapshotID, paths)
|
||||
|
||||
cmd := r.command(ctx, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
r.logger.Printf("[ERROR] Restore failed: %v, output: %s", err, truncate(string(output), 500))
|
||||
return fmt.Errorf("restic restore failed: %w", err)
|
||||
}
|
||||
|
||||
r.logger.Printf("[INFO] RESTORE completed: snapshot=%s, paths=%v", snapshotID, paths)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ResticManager) command(ctx context.Context, args ...string) *exec.Cmd {
|
||||
cmd := exec.CommandContext(ctx, "restic", args...)
|
||||
cmd.Env = append(os.Environ(),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package backup
|
||||
|
||||
import "fmt"
|
||||
|
||||
// RestoreApp restores an app's HDD data from a restic snapshot.
|
||||
func (m *Manager) RestoreApp(stackName, snapshotID string) error {
|
||||
// Validate app has backup enabled
|
||||
if !m.settings.IsAppBackupEnabled(stackName) {
|
||||
return fmt.Errorf("backup not enabled for %s", stackName)
|
||||
}
|
||||
|
||||
// Resolve HDD paths for this app
|
||||
if m.stackProvider == nil {
|
||||
return fmt.Errorf("stack provider not configured")
|
||||
}
|
||||
hddMounts := m.stackProvider.GetStackHDDMounts(stackName)
|
||||
if len(hddMounts) == 0 {
|
||||
return fmt.Errorf("no HDD data paths found for %s", stackName)
|
||||
}
|
||||
|
||||
// Validate snapshot exists
|
||||
snapshots, err := m.restic.ListSnapshots(100)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing snapshots: %w", err)
|
||||
}
|
||||
found := false
|
||||
for _, s := range snapshots {
|
||||
if s.ID == snapshotID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("snapshot %s not found", snapshotID)
|
||||
}
|
||||
|
||||
// Use the running flag to prevent concurrent backup/restore
|
||||
m.mu.Lock()
|
||||
if m.running {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("backup or restore already in progress")
|
||||
}
|
||||
m.running = true
|
||||
m.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
m.running = false
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
|
||||
m.logger.Printf("[WARN] RESTORE starting: stack=%s, snapshot=%s, paths=%v", stackName, snapshotID, hddMounts)
|
||||
|
||||
// Execute restore
|
||||
if err := m.restic.RestoreAppData(snapshotID, hddMounts); err != nil {
|
||||
m.logger.Printf("[ERROR] RESTORE failed for %s: %v", stackName, err)
|
||||
return err
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] RESTORE completed: stack=%s, snapshot=%s", stackName, snapshotID)
|
||||
return nil
|
||||
}
|
||||
@@ -172,6 +172,11 @@ func buildBackupReport(cfg *config.Config, backupMgr *backup.Manager) BackupRepo
|
||||
}
|
||||
br.IntegrityOK = status.LastCheckOK
|
||||
|
||||
// Include restic password for hub-side disaster recovery
|
||||
if pw, err := backupMgr.GetResticPassword(); err == nil {
|
||||
br.ResticPassword = pw
|
||||
}
|
||||
|
||||
return br
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ type BackupReport struct {
|
||||
RepoSizeMB int64 `json:"repo_size_mb"`
|
||||
LastIntegrityCheck *time.Time `json:"last_integrity_check,omitempty"`
|
||||
IntegrityOK bool `json:"integrity_ok"`
|
||||
ResticPassword string `json:"restic_password,omitempty"`
|
||||
}
|
||||
|
||||
// HealthReport holds the aggregated health status.
|
||||
|
||||
@@ -24,6 +24,14 @@ type Settings struct {
|
||||
|
||||
// Cached state
|
||||
DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`
|
||||
|
||||
// Per-app backup preferences
|
||||
AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`
|
||||
}
|
||||
|
||||
// AppBackupPrefs holds per-app backup toggle state.
|
||||
type AppBackupPrefs struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// NotificationPrefs holds customer notification preferences.
|
||||
@@ -170,3 +178,49 @@ func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error {
|
||||
s.Notifications = prefs
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// IsAppBackupEnabled returns whether backup is enabled for the given stack.
|
||||
func (s *Settings) IsAppBackupEnabled(stackName string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.AppBackup == nil {
|
||||
return false
|
||||
}
|
||||
return s.AppBackup[stackName].Enabled
|
||||
}
|
||||
|
||||
// SetAppBackup enables or disables backup for a stack and saves to disk.
|
||||
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}
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// GetAppBackupMap returns a map of stack_name -> enabled for all app backup prefs.
|
||||
func (s *Settings) GetAppBackupMap() map[string]bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.AppBackup == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]bool, len(s.AppBackup))
|
||||
for k, v := range s.AppBackup {
|
||||
result[k] = v.Enabled
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SetAppBackupBulk updates backup prefs for all stacks at once and saves to disk.
|
||||
func (s *Settings) SetAppBackupBulk(prefs map[string]bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.AppBackup = make(map[string]AppBackupPrefs, len(prefs))
|
||||
for name, enabled := range prefs {
|
||||
s.AppBackup[name] = AppBackupPrefs{Enabled: enabled}
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
|
||||
}
|
||||
|
||||
// Step 1: Parse compose file for HDD bind mounts
|
||||
hddMounts := parseComposeHDDMounts(stack.ComposePath, hddPath)
|
||||
hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
|
||||
|
||||
// Step 2: Run docker compose down --rmi local --volumes
|
||||
env := m.stackEnv(stackDir)
|
||||
@@ -164,7 +164,7 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
mounts := parseComposeHDDMounts(stack.ComposePath, hddPath)
|
||||
mounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
|
||||
protected := protectedHDDPaths(hddPath)
|
||||
|
||||
for _, mount := range mounts {
|
||||
@@ -197,9 +197,9 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// parseComposeHDDMounts reads a docker-compose.yml and extracts host paths
|
||||
// ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths
|
||||
// that reference the HDD path from volume bind mounts.
|
||||
func parseComposeHDDMounts(composePath, hddPath string) []string {
|
||||
func ParseComposeHDDMounts(composePath, hddPath string) []string {
|
||||
if hddPath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -237,14 +237,31 @@ func isPingConfigured(uuid string) bool {
|
||||
return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME")
|
||||
}
|
||||
|
||||
func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.baseData("backups", "Biztonsági mentés")
|
||||
|
||||
// System info for storage overview bars
|
||||
data["SystemInfo"] = system.GetInfo(s.cfg.Paths.HDDPath, s.cpuCollector)
|
||||
|
||||
if s.backupMgr != nil {
|
||||
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
||||
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
|
||||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
|
||||
|
||||
// Pass flash messages from query params (set by redirect handlers)
|
||||
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||||
fullStatus.FlashSuccess = flash
|
||||
}
|
||||
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
|
||||
fullStatus.FlashError = flashErr
|
||||
}
|
||||
|
||||
data["Backup"] = fullStatus
|
||||
|
||||
// Restic password for display
|
||||
if pw, err := s.backupMgr.GetResticPassword(); err == nil {
|
||||
data["ResticPassword"] = pw
|
||||
}
|
||||
} else {
|
||||
data["Backup"] = nil
|
||||
}
|
||||
@@ -252,6 +269,69 @@ func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
s.render(w, "backups", data)
|
||||
}
|
||||
|
||||
func (s *Server) settingsAppBackupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
if s.backupMgr == nil {
|
||||
http.Redirect(w, r, "/backups", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current app data info to know which stacks have HDD data
|
||||
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
||||
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
|
||||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
|
||||
|
||||
prefs := make(map[string]bool)
|
||||
for _, app := range fullStatus.AppDataInfo {
|
||||
if app.HasHDDData {
|
||||
prefs[app.StackName] = r.FormValue("backup_"+app.StackName) == "on"
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.settings.SetAppBackupBulk(prefs); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to save app backup prefs: %v", err)
|
||||
http.Redirect(w, r, "/backups?flash_error=Hiba+a+ment%C3%A9skor", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] App backup preferences updated: %v", prefs)
|
||||
|
||||
// Trigger cache refresh so the page shows updated data
|
||||
go s.backupMgr.RefreshCache(nextDBDump, nextBackup)
|
||||
|
||||
http.Redirect(w, r, "/backups?flash=Alkalmaz%C3%A1s+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1sok+mentve.", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
stackName := r.FormValue("stack_name")
|
||||
snapshotID := r.FormValue("snapshot_id")
|
||||
|
||||
if stackName == "" || snapshotID == "" {
|
||||
http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if s.backupMgr == nil {
|
||||
http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[WARN] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
|
||||
|
||||
if err := s.backupMgr.RestoreApp(stackName, snapshotID); err != nil {
|
||||
s.logger.Printf("[ERROR] Restore failed: %v", err)
|
||||
errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error())
|
||||
http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").")
|
||||
http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) settingsData() map[string]interface{} {
|
||||
data := s.baseData("settings", "Beállítások")
|
||||
data["CustomerID"] = s.cfg.Customer.ID
|
||||
|
||||
@@ -94,6 +94,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.settingsNotificationsHandler(w, r)
|
||||
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
|
||||
s.settingsNotificationsTestHandler(w, r)
|
||||
case path == "/settings/app-backup" && r.Method == http.MethodPost:
|
||||
s.settingsAppBackupHandler(w, r)
|
||||
case path == "/backup/restore" && r.Method == http.MethodPost:
|
||||
s.backupRestoreHandler(w, r)
|
||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
|
||||
name := strings.TrimPrefix(path, "/stacks/")
|
||||
name = strings.TrimSuffix(name, "/logs")
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
</div>
|
||||
|
||||
{{if .Backup}}{{if .Backup.FlashSuccess}}
|
||||
<div class="flash flash-success">{{.Backup.FlashSuccess}}</div>
|
||||
{{end}}{{end}}
|
||||
{{if .Backup}}{{if .Backup.FlashError}}
|
||||
<div class="flash flash-error">{{.Backup.FlashError}}</div>
|
||||
{{end}}{{end}}
|
||||
|
||||
{{if not .Backup}}
|
||||
<div class="backup-empty-state">
|
||||
<div class="backup-empty-icon">🛡</div>
|
||||
@@ -15,6 +22,53 @@
|
||||
</div>
|
||||
{{else}}
|
||||
|
||||
<!-- Section 0: Storage overview -->
|
||||
<div class="backup-section-card">
|
||||
<h3>Tárhely áttekintés</h3>
|
||||
<div class="storage-overview-grid">
|
||||
<div class="storage-bars">
|
||||
{{with $.SystemInfo}}
|
||||
<div class="storage-item">
|
||||
<div class="storage-header">
|
||||
<span class="storage-label">SSD (/)</span>
|
||||
<span class="storage-value">{{fmtGB .DiskUsedGB}} / {{fmtGB .DiskTotalGB}} ({{printf "%.0f" .DiskPercent}}%)</span>
|
||||
</div>
|
||||
<div class="system-bar">
|
||||
<div class="system-bar-fill {{usageColor .DiskPercent | printf "system-bar-%s"}}" style="width:{{printf "%.1f" .DiskPercent}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{if .HDDConfigured}}
|
||||
<div class="storage-item">
|
||||
<div class="storage-header">
|
||||
<span class="storage-label">Külső HDD</span>
|
||||
<span class="storage-value">{{fmtGB .HDDUsedGB}} / {{fmtGB .HDDTotalGB}} ({{printf "%.0f" .HDDPercent}}%)</span>
|
||||
</div>
|
||||
<div class="system-bar">
|
||||
<div class="system-bar-fill {{usageColor .HDDPercent | printf "system-bar-%s"}}" style="width:{{printf "%.1f" .HDDPercent}}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="storage-stats">
|
||||
{{if .Backup.RepoStats}}
|
||||
<div class="storage-stat-row">
|
||||
<span class="storage-stat-label">Mentési tároló</span>
|
||||
<span class="storage-stat-value mono">{{.Backup.RepoStats.TotalSize}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="storage-stat-row">
|
||||
<span class="storage-stat-label">DB mentések</span>
|
||||
<span class="storage-stat-value mono">{{if .Backup.DumpFiles}}{{len .Backup.DumpFiles}} fájl{{else}}–{{end}}</span>
|
||||
</div>
|
||||
<div class="storage-stat-row">
|
||||
<span class="storage-stat-label">Pillanatképek</span>
|
||||
<span class="storage-stat-value mono">{{if .Backup.RepoStats}}{{.Backup.RepoStats.SnapshotCount}}{{else}}0{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: Status overview cards -->
|
||||
<div class="stats-grid backup-page-cards">
|
||||
{{if .Backup.LastBackup}}
|
||||
@@ -179,7 +233,56 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Section 4: Snapshots -->
|
||||
<!-- Section 4: App data backup toggles -->
|
||||
{{if .Backup.AppDataInfo}}
|
||||
<div class="backup-section-card">
|
||||
<h3>Alkalmazás adatok</h3>
|
||||
<p class="backup-section-desc">Az alkalmazások felhasználói adatainak biztonsági mentése.</p>
|
||||
<form method="POST" action="/settings/app-backup">
|
||||
<div class="app-backup-list">
|
||||
{{range .Backup.AppDataInfo}}
|
||||
<div class="app-backup-item">
|
||||
<div class="app-backup-header">
|
||||
{{if .HasHDDData}}
|
||||
<label class="app-backup-toggle">
|
||||
<input type="checkbox" name="backup_{{.StackName}}" value="on" {{if .BackupEnabled}}checked{{end}}>
|
||||
<span class="app-backup-name">{{.DisplayName}}</span>
|
||||
</label>
|
||||
{{else}}
|
||||
<div class="app-backup-toggle">
|
||||
<span class="app-backup-disabled-icon">—</span>
|
||||
<span class="app-backup-name">{{.DisplayName}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .HasHDDData}}
|
||||
<span class="app-backup-size mono">{{.HDDSizeHuman}} (HDD)</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="app-backup-details">
|
||||
{{range .HDDPaths}}
|
||||
<div class="app-backup-path mono">{{.HostPath}} {{if .Exists}}({{.SizeHuman}}){{else}}<span class="relative-time">(nem létezik)</span>{{end}}</div>
|
||||
{{end}}
|
||||
{{range .DockerVolumes}}
|
||||
<div class="app-backup-volume">Docker kötet: {{.Name}} <span class="relative-time">(nem mentett)</span></div>
|
||||
{{end}}
|
||||
{{if .HasDBDump}}
|
||||
<div class="app-backup-dbinfo">Adatbázis mentés naponta (DB dump)</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="app-backup-actions">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Mentés</button>
|
||||
</div>
|
||||
<div class="app-backup-notice">
|
||||
<span class="relative-time">Docker kötetek mentése jelenleg nem támogatott. Az adatbázisokat az automatikus DB dump menti naponta.</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Section 5: Snapshots -->
|
||||
<div class="backup-section-card">
|
||||
<h3>Pillanatképek</h3>
|
||||
{{if .Backup.SnapshotHistory}}
|
||||
@@ -216,7 +319,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Section 5: Repository -->
|
||||
<!-- Section 6: Repository -->
|
||||
<div class="repo-card">
|
||||
<h3>Tároló</h3>
|
||||
<div class="repo-info-rows">
|
||||
@@ -247,6 +350,22 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Encryption key -->
|
||||
{{if $.ResticPassword}}
|
||||
<div class="repo-encryption">
|
||||
<span class="repo-label">Titkosítási kulcs:</span>
|
||||
<div class="repo-encryption-row">
|
||||
<input type="password" id="restic-pw" class="restic-pw-field mono" value="{{$.ResticPassword}}" readonly>
|
||||
<button type="button" class="btn btn-sm" onclick="toggleResticPw()">Megjelenítés</button>
|
||||
<button type="button" class="btn btn-sm" onclick="copyResticPw()">Másolás</button>
|
||||
</div>
|
||||
<div class="repo-encryption-warn">
|
||||
Mentse el biztonságos helyre! A kulcs nélkül a biztonsági mentések NEM állíthatók vissza.
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="repo-paths">
|
||||
<span class="repo-label">Mentett útvonalak:</span>
|
||||
<ul class="repo-path-list">
|
||||
@@ -264,6 +383,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 7: Restore -->
|
||||
{{if .Backup.AppDataInfo}}
|
||||
<div class="backup-section-card">
|
||||
<h3>Visszaállítás</h3>
|
||||
<div class="restore-section">
|
||||
<div class="restore-form-row">
|
||||
<label class="restore-label">Alkalmazás:</label>
|
||||
<select id="restore-app" class="restore-select" onchange="onRestoreAppChange()">
|
||||
<option value="">— Válasszon —</option>
|
||||
{{range .Backup.AppDataInfo}}
|
||||
{{if and .HasHDDData .BackupEnabled}}
|
||||
<option value="{{.StackName}}" data-paths="{{range $i, $p := .HDDPaths}}{{if $i}},{{end}}{{$p.HostPath}}{{end}}">{{.DisplayName}}</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="restore-form-row">
|
||||
<label class="restore-label">Pillanatkép:</label>
|
||||
<select id="restore-snapshot" class="restore-select">
|
||||
<option value="">— Betöltés... —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="restore-paths" class="restore-paths" style="display:none;">
|
||||
<span class="restore-label">Visszaállítandó útvonalak:</span>
|
||||
<ul id="restore-paths-list" class="repo-path-list"></ul>
|
||||
</div>
|
||||
<div class="restore-warning">
|
||||
<strong>FIGYELMEZTETÉS</strong><br>
|
||||
A visszaállítás FELÜLÍRJA a kiválasztott alkalmazás jelenlegi adatait a mentés pillanatának állapotával.
|
||||
Ez a művelet NEM vonható vissza!<br>
|
||||
Javasoljuk az alkalmazás leállítását a visszaállítás előtt.
|
||||
</div>
|
||||
<div class="restore-confirm">
|
||||
<label>
|
||||
<input type="checkbox" id="restore-confirm-cb" onchange="onRestoreConfirmChange()">
|
||||
Megértettem, visszaállítás saját felelősségre.
|
||||
</label>
|
||||
</div>
|
||||
<div class="restore-actions">
|
||||
<button type="button" class="btn btn-sm btn-danger" id="restore-btn" disabled onclick="submitRestore()">Visszaállítás indítása</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
||||
|
||||
<script>
|
||||
@@ -303,10 +467,105 @@ function startBackupPolling() {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Restic password toggle/copy
|
||||
function toggleResticPw() {
|
||||
var el = document.getElementById('restic-pw');
|
||||
el.type = el.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
function copyResticPw() {
|
||||
var el = document.getElementById('restic-pw');
|
||||
navigator.clipboard.writeText(el.value).then(function() {
|
||||
var btn = event.target;
|
||||
btn.textContent = 'Másolva!';
|
||||
setTimeout(function() { btn.textContent = 'Másolás'; }, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Restore section
|
||||
function onRestoreAppChange() {
|
||||
var sel = document.getElementById('restore-app');
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
var pathsDiv = document.getElementById('restore-paths');
|
||||
var pathsList = document.getElementById('restore-paths-list');
|
||||
|
||||
if (!opt.value) {
|
||||
pathsDiv.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
var paths = (opt.getAttribute('data-paths') || '').split(',').filter(Boolean);
|
||||
pathsList.innerHTML = '';
|
||||
paths.forEach(function(p) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'mono';
|
||||
li.textContent = p;
|
||||
pathsList.appendChild(li);
|
||||
});
|
||||
pathsDiv.style.display = 'block';
|
||||
|
||||
// Load snapshots
|
||||
fetch('/api/backup/snapshots')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var snapSel = document.getElementById('restore-snapshot');
|
||||
snapSel.innerHTML = '<option value="">— Válasszon —</option>';
|
||||
if (data.ok && data.data) {
|
||||
data.data.forEach(function(s) {
|
||||
var o = document.createElement('option');
|
||||
o.value = s.short_id;
|
||||
var t = new Date(s.time);
|
||||
o.textContent = s.short_id + ' — ' + t.toLocaleString('hu-HU');
|
||||
snapSel.appendChild(o);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('restore-confirm-cb').checked = false;
|
||||
document.getElementById('restore-btn').disabled = true;
|
||||
}
|
||||
|
||||
function onRestoreConfirmChange() {
|
||||
var cb = document.getElementById('restore-confirm-cb');
|
||||
var app = document.getElementById('restore-app').value;
|
||||
var snap = document.getElementById('restore-snapshot').value;
|
||||
document.getElementById('restore-btn').disabled = !(cb.checked && app && snap);
|
||||
}
|
||||
|
||||
function submitRestore() {
|
||||
var app = document.getElementById('restore-app').value;
|
||||
var snap = document.getElementById('restore-snapshot').value;
|
||||
if (!app || !snap) return;
|
||||
|
||||
var btn = document.getElementById('restore-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Visszaállítás folyamatban...';
|
||||
|
||||
var form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/backup/restore';
|
||||
|
||||
var f1 = document.createElement('input');
|
||||
f1.type = 'hidden'; f1.name = 'stack_name'; f1.value = app;
|
||||
form.appendChild(f1);
|
||||
|
||||
var f2 = document.createElement('input');
|
||||
f2.type = 'hidden'; f2.name = 'snapshot_id'; f2.value = snap;
|
||||
form.appendChild(f2);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
// Auto-poll if backup is already running on page load
|
||||
{{if .Backup}}{{if .Backup.Running}}
|
||||
startBackupPolling();
|
||||
{{end}}{{end}}
|
||||
|
||||
// Wire up snapshot selection change for restore confirm
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var snapSel = document.getElementById('restore-snapshot');
|
||||
if (snapSel) snapSel.addEventListener('change', onRestoreConfirmChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
|
||||
@@ -1769,6 +1769,213 @@ a.stat-card:hover {
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
/* --- Backup page: Storage overview grid --- */
|
||||
.storage-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
.storage-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
.storage-stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: .3rem 0;
|
||||
font-size: .85rem;
|
||||
}
|
||||
.storage-stat-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.storage-stat-value {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* --- Backup page: App backup toggles --- */
|
||||
.backup-section-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: .85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.app-backup-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.app-backup-item {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: .75rem 1rem;
|
||||
}
|
||||
.app-backup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: .25rem;
|
||||
}
|
||||
.app-backup-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.app-backup-toggle input[type="checkbox"] {
|
||||
accent-color: var(--accent-blue);
|
||||
}
|
||||
.app-backup-name {
|
||||
font-weight: 500;
|
||||
font-size: .9rem;
|
||||
}
|
||||
.app-backup-disabled-icon {
|
||||
color: var(--text-muted);
|
||||
font-size: .9rem;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.app-backup-size {
|
||||
font-size: .8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.app-backup-details {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.app-backup-path {
|
||||
font-size: .8rem;
|
||||
color: var(--text-secondary);
|
||||
padding: .1rem 0;
|
||||
}
|
||||
.app-backup-volume {
|
||||
font-size: .8rem;
|
||||
color: var(--text-muted);
|
||||
padding: .1rem 0;
|
||||
}
|
||||
.app-backup-dbinfo {
|
||||
font-size: .8rem;
|
||||
color: var(--text-muted);
|
||||
padding: .1rem 0;
|
||||
}
|
||||
.app-backup-actions {
|
||||
margin-top: .75rem;
|
||||
}
|
||||
.app-backup-notice {
|
||||
margin-top: .75rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
/* --- Backup page: Encryption key --- */
|
||||
.repo-encryption {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: .75rem;
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
.repo-encryption-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
margin-top: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
.restic-pw-field {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: .4rem .6rem;
|
||||
color: var(--text-primary);
|
||||
font-size: .85rem;
|
||||
width: 340px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.repo-encryption-warn {
|
||||
font-size: .8rem;
|
||||
color: var(--yellow);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* --- Backup page: Restore section --- */
|
||||
.restore-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .75rem;
|
||||
}
|
||||
.restore-form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
}
|
||||
.restore-label {
|
||||
font-size: .85rem;
|
||||
color: var(--text-secondary);
|
||||
min-width: 110px;
|
||||
}
|
||||
.restore-select {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
padding: .4rem .6rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: .85rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.restore-select option {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.restore-paths {
|
||||
padding: .5rem 0;
|
||||
}
|
||||
.restore-warning {
|
||||
background: var(--red-bg);
|
||||
border: 1px solid rgba(218, 54, 51, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: .75rem 1rem;
|
||||
font-size: .85rem;
|
||||
color: var(--red);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.restore-confirm {
|
||||
font-size: .85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.restore-confirm label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.restore-confirm input[type="checkbox"] {
|
||||
accent-color: var(--red);
|
||||
}
|
||||
.restore-actions {
|
||||
padding-top: .25rem;
|
||||
}
|
||||
|
||||
/* --- Flash messages --- */
|
||||
.flash {
|
||||
padding: .75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: .85rem;
|
||||
border: 1px solid;
|
||||
}
|
||||
.flash-success {
|
||||
background: var(--green-bg);
|
||||
color: var(--green);
|
||||
border-color: rgba(35, 134, 54, 0.3);
|
||||
}
|
||||
.flash-error {
|
||||
background: var(--red-bg);
|
||||
color: var(--red);
|
||||
border-color: rgba(218, 54, 51, 0.3);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media(max-width: 768px) {
|
||||
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
|
||||
@@ -1786,4 +1993,8 @@ a.stat-card:hover {
|
||||
.charts-grid { grid-template-columns: 1fr; }
|
||||
.container-charts-row { flex-direction: column; }
|
||||
.sysinfo-grid { grid-template-columns: 1fr; }
|
||||
.storage-overview-grid { grid-template-columns: 1fr; }
|
||||
.restore-form-row { flex-direction: column; align-items: stretch; }
|
||||
.restore-label { min-width: auto; }
|
||||
.restore-select { max-width: 100%; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user