69698a89e8
- Health severity fix: mount-point check downgraded from issue (FAIL) to warning (WARN) - All storage health messages translated to Hungarian - Success flash messages for all storage operations - Edit storage path labels (inline edit UI + backend) - App details per storage path on settings page (expandable list with names + sizes) - Storage badge on stacks page showing which storage each app uses - Deploy dropdown with free space display and low-space warning (<20%) - Filesystem & disk info on settings page (ext4/btrfs, device, model via findmnt) - Backup page storage context with per-app storage label badges Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
183 lines
4.4 KiB
Go
183 lines
4.4 KiB
Go
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
|
|
StorageLabel string // resolved from registered storage paths
|
|
}
|
|
|
|
// 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, 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)
|
|
}
|
|
}
|