feat: comprehensive debug logging across all controller modules

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>
This commit is contained in:
2026-02-26 18:14:43 +01:00
parent f6caea8067
commit 95c821deb2
54 changed files with 5015 additions and 82 deletions
+8
View File
@@ -30,6 +30,7 @@ func NewCPUCollector(sampleRate time.Duration) *CPUCollector {
// Start begins background CPU sampling.
func (c *CPUCollector) Start(ctx context.Context) {
ctx, c.cancel = context.WithCancel(ctx)
debugf("[DEBUG] [system] CPUCollector.Start: sampleRate=%s", c.sampleRate)
go c.loop(ctx)
}
@@ -48,10 +49,12 @@ func (c *CPUCollector) CPUPercent() float64 {
}
func (c *CPUCollector) loop(ctx context.Context) {
firstSample := true
for {
// Read first sample
idle1, total1, err := readCPUStat()
if err != nil {
debugf("[DEBUG] [system] CPUCollector: readCPUStat error: %v", err)
select {
case <-ctx.Done():
return
@@ -82,6 +85,11 @@ func (c *CPUCollector) loop(ctx context.Context) {
c.mu.Lock()
c.cpuPercent = percent
c.mu.Unlock()
if firstSample {
debugf("[DEBUG] [system] CPUCollector: first sample — cpu=%.1f%% (idle=%d total=%d)", percent, idleDelta, totalDelta)
firstSample = false
}
}
}
}
+14
View File
@@ -1,5 +1,19 @@
package system
import "log"
// DebugLogger, when non-nil, enables debug-level logging for the system package.
// Set this to the application's *log.Logger when config logging.level == "debug".
// When nil, no debug output is emitted.
var DebugLogger *log.Logger
// debugf logs a formatted message if DebugLogger is set.
func debugf(format string, args ...any) {
if DebugLogger != nil {
DebugLogger.Printf(format, args...)
}
}
// SystemInfo holds system resource usage information.
type SystemInfo struct {
TotalMemMB uint64 `json:"total_mem_mb"`
+29
View File
@@ -10,12 +10,16 @@ import (
"sort"
"strings"
"syscall"
"time"
)
// GetInfo reads system memory, disk, CPU, load, and temperature info.
// hddPath is the mount path for external HDD; if empty, HDD info is skipped.
// cpuCollector provides the latest CPU usage sample; may be nil.
func GetInfo(hddPath string, cpuCollector *CPUCollector) SystemInfo {
start := time.Now()
debugf("[DEBUG] [system] GetInfo starting (hddPath=%q, hasCPUCollector=%v)", hddPath, cpuCollector != nil)
info := SystemInfo{}
// --- Memory from /proc/meminfo ---
@@ -41,6 +45,15 @@ func GetInfo(hddPath string, cpuCollector *CPUCollector) SystemInfo {
info.CPUPercent = cpuCollector.CPUPercent()
}
debugf("[DEBUG] [system] GetInfo done in %s — mem=%dMB/%dMB (%.1f%%), rootDisk=%.1fGB/%.1fGB (%.1f%%), load=%.2f/%.2f/%.2f, temp=%.1f°C (%s), cpu=%.1f%%",
time.Since(start).Round(time.Millisecond),
info.UsedMemMB, info.TotalMemMB, info.MemPercent,
info.DiskUsedGB, info.DiskTotalGB, info.DiskPercent,
info.LoadAvg1, info.LoadAvg5, info.LoadAvg15,
info.TemperatureCelsius, info.TemperatureSource,
info.CPUPercent,
)
return info
}
@@ -67,6 +80,7 @@ func GetMemoryMB() (totalMB, usedMB int, err error) {
func readMemInfo(info *SystemInfo) {
f, err := os.Open("/proc/meminfo")
if err != nil {
debugf("[DEBUG] [system] readMemInfo: failed to open /proc/meminfo: %v", err)
return
}
defer f.Close()
@@ -91,6 +105,10 @@ func readMemInfo(info *SystemInfo) {
info.AvailMemMB = availKB / 1024
info.UsedMemMB = info.TotalMemMB - info.AvailMemMB
info.MemPercent = float64(info.UsedMemMB) / float64(info.TotalMemMB) * 100
debugf("[DEBUG] [system] readMemInfo: totalKB=%d availKB=%d → total=%dMB avail=%dMB used=%dMB (%.1f%%)",
totalKB, availKB, info.TotalMemMB, info.AvailMemMB, info.UsedMemMB, info.MemPercent)
} else {
debugf("[DEBUG] [system] readMemInfo: could not parse MemTotal from /proc/meminfo")
}
}
@@ -116,6 +134,7 @@ func parseMemLine(line string) uint64 {
func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *float64) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
debugf("[DEBUG] [system] readDiskUsage: statfs(%q) failed: %v", path, err)
return
}
@@ -131,15 +150,20 @@ func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *floa
if total > 0 {
*percent = float64(used) / float64(total) * 100
}
debugf("[DEBUG] [system] readDiskUsage: path=%q bsize=%d total=%.1fGB used=%.1fGB avail=%.1fGB (%.1f%%)",
path, bsize, *totalGB, *usedGB, *availGB, *percent)
}
// readLoadAvg reads 1/5/15 minute load averages from /proc/loadavg.
func readLoadAvg(info *SystemInfo) {
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
debugf("[DEBUG] [system] readLoadAvg: failed to read /proc/loadavg: %v", err)
return
}
fmt.Sscanf(string(data), "%f %f %f", &info.LoadAvg1, &info.LoadAvg5, &info.LoadAvg15)
debugf("[DEBUG] [system] readLoadAvg: raw=%q → 1m=%.2f 5m=%.2f 15m=%.2f",
strings.TrimSpace(string(data)), info.LoadAvg1, info.LoadAvg5, info.LoadAvg15)
}
// readTemperature reads CPU/SoC temperature from thermal zones.
@@ -149,6 +173,7 @@ func readTemperature(info *SystemInfo) {
for _, prefix := range prefixes {
if readThermalZones(prefix, info) {
debugf("[DEBUG] [system] readTemperature: found via thermal_zone at %s — %.1f°C (%s)", prefix, info.TemperatureCelsius, info.TemperatureSource)
return
}
}
@@ -156,9 +181,12 @@ func readTemperature(info *SystemInfo) {
// Fallback: try hwmon
for _, prefix := range prefixes {
if readHwmon(prefix, info) {
debugf("[DEBUG] [system] readTemperature: found via hwmon at %s — %.1f°C (%s)", prefix, info.TemperatureCelsius, info.TemperatureSource)
return
}
}
debugf("[DEBUG] [system] readTemperature: no temperature source found")
}
func readThermalZones(sysPrefix string, info *SystemInfo) bool {
@@ -169,6 +197,7 @@ func readThermalZones(sysPrefix string, info *SystemInfo) bool {
}
sort.Strings(matches)
debugf("[DEBUG] [system] readThermalZones: %s — found %d zones", sysPrefix, len(matches))
var maxTemp float64
var maxSource string
+25 -4
View File
@@ -65,6 +65,7 @@ type DiskUsageInfo struct {
func GetDiskUsage(path string) *DiskUsageInfo {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
debugf("[DEBUG] [system] GetDiskUsage: statfs(%q) failed: %v", path, err)
return nil
}
@@ -84,6 +85,8 @@ func GetDiskUsage(path string) *DiskUsageInfo {
}
info.TotalHuman = formatGB(info.TotalGB)
info.UsedHuman = formatGB(info.UsedGB)
debugf("[DEBUG] [system] GetDiskUsage: path=%q total=%s used=%s avail=%.1fGB (%.1f%%)",
path, info.TotalHuman, info.UsedHuman, info.AvailGB, info.UsedPercent)
return info
}
@@ -105,10 +108,12 @@ type FSInfo struct {
func GetFSInfo(path string) *FSInfo {
out, err := exec.Command("findmnt", "-n", "-o", "SOURCE,FSTYPE", "--target", path).Output()
if err != nil {
debugf("[DEBUG] [system] GetFSInfo: findmnt(%q) failed: %v", path, err)
return nil
}
fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) < 2 {
debugf("[DEBUG] [system] GetFSInfo: findmnt(%q) returned unexpected output: %q", path, strings.TrimSpace(string(out)))
return nil
}
info := &FSInfo{
@@ -117,6 +122,7 @@ func GetFSInfo(path string) *FSInfo {
}
// Try to get disk model from sysfs
info.Model = diskModel(info.Device)
debugf("[DEBUG] [system] GetFSInfo: path=%q device=%s fstype=%s model=%q", path, info.Device, info.FSType, info.Model)
return info
}
@@ -136,6 +142,7 @@ type DestinationHealth struct {
// CheckBackupDestination performs tiered validation of a cross-drive backup destination.
// Returns a DestinationHealth describing any issues found.
func CheckBackupDestination(path string) DestinationHealth {
debugf("[DEBUG] [system] CheckBackupDestination: path=%q", path)
h := DestinationHealth{Severity: "ok"}
// Tier 1: path must exist
@@ -143,6 +150,7 @@ func CheckBackupDestination(path string) DestinationHealth {
h.Warning = "A cél tárhely (" + path + ") nem létezik!"
h.Blocked = true
h.Severity = "critical"
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier1 FAIL (not exists)", path)
return h
}
h.Exists = true
@@ -152,6 +160,7 @@ func CheckBackupDestination(path string) DestinationHealth {
h.Warning = "A cél tárhely (" + path + ") nem írható! Ellenőrizd a jogosultságokat."
h.Blocked = true
h.Severity = "critical"
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier2 FAIL (not writable)", path)
return h
}
h.Writable = true
@@ -165,9 +174,11 @@ func CheckBackupDestination(path string) DestinationHealth {
"Meghajtóhiba esetén az eredeti adat és a mentés is elveszhet. " +
"Külső meghajtó használata javasolt."
h.Severity = "warning"
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier3 WARN (same block device as /)", path)
// Don't return early — also check disk usage
} else {
h.MountPoint = true
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier3 OK (different block device)", path)
}
// Tier 4: disk usage checks
@@ -199,6 +210,8 @@ func CheckBackupDestination(path string) DestinationHealth {
}
}
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — result: severity=%s blocked=%v freeGB=%.1f usedPct=%.1f%%",
path, h.Severity, h.Blocked, h.FreeGB, h.UsedPercent)
return h
}
@@ -256,8 +269,11 @@ type ProbeResult struct {
// ProbeStoragePath checks if a storage path is responsive.
// Uses a goroutine with a 3-second timeout to avoid blocking on dead mounts.
func ProbeStoragePath(path string) ProbeResult {
start := time.Now()
// Quick check: does the path exist at all?
if _, err := os.Lstat(path); os.IsNotExist(err) {
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — not exists (%s)", path, time.Since(start).Round(time.Millisecond))
return ProbeResult{Status: ProbeDisconnected, Err: err}
}
@@ -273,17 +289,22 @@ func ProbeStoragePath(path string) ProbeResult {
select {
case res := <-ch:
elapsed := time.Since(start).Round(time.Millisecond)
if res.err == nil {
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — connected (%s)", path, elapsed)
return ProbeResult{Status: ProbeConnected}
}
errStr := res.err.Error()
if strings.Contains(errStr, "transport endpoint") ||
strings.Contains(errStr, "input/output error") ||
strings.Contains(errStr, "no such device") {
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — disconnected: %v (%s)", path, res.err, elapsed)
return ProbeResult{Status: ProbeDisconnected, Err: res.err}
}
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — disconnected (other error): %v (%s)", path, res.err, elapsed)
return ProbeResult{Status: ProbeDisconnected, Err: res.err}
case <-time.After(3 * time.Second):
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — TIMEOUT (3s)", path)
return ProbeResult{Status: ProbeTimeout, Err: fmt.Errorf("stat timed out after 3s")}
}
}
@@ -302,11 +323,11 @@ func IsUSBDevice(devicePath string) bool {
if err != nil {
continue
}
if strings.Contains(link, "/usb") {
return true
}
return false // found the sysfs entry, but not USB
isUSB := strings.Contains(link, "/usb")
debugf("[DEBUG] [system] IsUSBDevice: device=%q disk=%q sysfs=%s → usb=%v", devicePath, disk, link, isUSB)
return isUSB
}
debugf("[DEBUG] [system] IsUSBDevice: device=%q disk=%q — no sysfs entry found", devicePath, disk)
return false
}