95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
178 lines
5.2 KiB
Go
178 lines
5.2 KiB
Go
package appexport
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ExportEstimate holds pre-export size and space estimation.
|
|
type ExportEstimate struct {
|
|
ConfigSizeBytes int64 `json:"config_size_bytes"`
|
|
ConfigSizeHuman string `json:"config_size_human"`
|
|
DataSizeBytes int64 `json:"data_size_bytes"`
|
|
DataSizeHuman string `json:"data_size_human"`
|
|
TotalSizeBytes int64 `json:"total_size_bytes"`
|
|
TotalSizeHuman string `json:"total_size_human"`
|
|
EstimatedMinutes int `json:"estimated_minutes"`
|
|
DestFreeBytes int64 `json:"dest_free_bytes"`
|
|
DestFreeHuman string `json:"dest_free_human"`
|
|
FitsOnDest bool `json:"fits_on_dest"`
|
|
}
|
|
|
|
// EstimateExport calculates size estimates for an app export.
|
|
func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate, error) {
|
|
stackDir, ok := e.provider.GetStackDir(stackName)
|
|
if !ok {
|
|
return nil, fmt.Errorf("stack %q not found", stackName)
|
|
}
|
|
|
|
e.debugf("EstimateExport: stack=%s stackDir=%s destDrive=%s", stackName, stackDir, destDrive)
|
|
est := &ExportEstimate{}
|
|
|
|
// Config size: sum of all files in the stack directory
|
|
est.ConfigSizeBytes = dirSize(stackDir)
|
|
est.ConfigSizeHuman = humanizeBytes(est.ConfigSizeBytes)
|
|
e.debugf("EstimateExport: configSize=%s (%d bytes)", est.ConfigSizeHuman, est.ConfigSizeBytes)
|
|
|
|
// Data size: HDD bind mounts or Docker volumes
|
|
if e.provider.GetStackNeedsHDD(stackName) {
|
|
mounts := e.provider.GetStackHDDMounts(stackName)
|
|
e.debugf("EstimateExport: HDD mounts: %v", mounts)
|
|
for _, mount := range mounts {
|
|
mountSize := duBytes(mount)
|
|
e.debugf("EstimateExport: mount %s = %s", mount, humanizeBytes(mountSize))
|
|
est.DataSizeBytes += mountSize
|
|
}
|
|
} else {
|
|
volumes := e.provider.GetDockerVolumes(stackName)
|
|
e.debugf("EstimateExport: Docker volumes: %v", volumes)
|
|
for _, vol := range volumes {
|
|
volSize := dockerVolumeSize(vol)
|
|
e.debugf("EstimateExport: volume %s = %s", vol, humanizeBytes(volSize))
|
|
est.DataSizeBytes += volSize
|
|
}
|
|
}
|
|
est.DataSizeHuman = humanizeBytes(est.DataSizeBytes)
|
|
|
|
est.TotalSizeBytes = est.ConfigSizeBytes + est.DataSizeBytes
|
|
est.TotalSizeHuman = humanizeBytes(est.TotalSizeBytes)
|
|
|
|
// Rough time estimate: ~500 MB/min for HDDs, minimum 1 minute
|
|
minutes := int(est.TotalSizeBytes / (500 * 1024 * 1024))
|
|
if minutes < 1 {
|
|
minutes = 1
|
|
}
|
|
est.EstimatedMinutes = minutes
|
|
|
|
// Destination free space
|
|
exportDir := ExportDir(destDrive)
|
|
os.MkdirAll(exportDir, 0755)
|
|
est.DestFreeBytes = diskFree(exportDir)
|
|
est.DestFreeHuman = humanizeBytes(est.DestFreeBytes)
|
|
|
|
// Need ~10% overhead for tar.gz metadata + compression margin
|
|
needed := est.TotalSizeBytes + est.TotalSizeBytes/10
|
|
est.FitsOnDest = est.DestFreeBytes >= needed
|
|
|
|
e.debugf("EstimateExport: total=%s free=%s fits=%v needed=%s minutes=%d",
|
|
est.TotalSizeHuman, est.DestFreeHuman, est.FitsOnDest, humanizeBytes(needed), est.EstimatedMinutes)
|
|
|
|
return est, nil
|
|
}
|
|
|
|
// dirSize returns the total size of all files in a directory (non-recursive for config dirs).
|
|
func dirSize(dir string) int64 {
|
|
var total int64
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
total += info.Size()
|
|
}
|
|
return total
|
|
}
|
|
|
|
// duBytes runs du -sb on a path and returns the byte count.
|
|
func duBytes(path string) int64 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, "du", "-sb", path).Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
var size int64
|
|
fmt.Sscanf(strings.Fields(string(out))[0], "%d", &size)
|
|
return size
|
|
}
|
|
|
|
// dockerVolumeSize estimates the size of a Docker named volume.
|
|
func dockerVolumeSize(volumeName string) int64 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
// Use docker system df -v and parse, or inspect the volume mount path
|
|
out, err := exec.CommandContext(ctx, "docker", "volume", "inspect",
|
|
"--format", "{{.Mountpoint}}", volumeName).Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
mountpoint := strings.TrimSpace(string(out))
|
|
if mountpoint == "" {
|
|
return 0
|
|
}
|
|
return duBytes(mountpoint)
|
|
}
|
|
|
|
// diskFree returns available bytes on the filesystem containing path.
|
|
func diskFree(path string) int64 {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, "df", "--output=avail", "-B1", path).Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
|
if len(lines) < 2 {
|
|
return 0
|
|
}
|
|
var size int64
|
|
fmt.Sscanf(strings.TrimSpace(lines[1]), "%d", &size)
|
|
return size
|
|
}
|
|
|
|
// ExportDir returns the exports directory on a drive.
|
|
func ExportDir(drivePath string) string {
|
|
return filepath.Join(drivePath, "felhom-data", "exports")
|
|
}
|
|
|
|
// humanizeBytes converts bytes to human-readable format.
|
|
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)
|
|
}
|
|
}
|