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>
278 lines
7.5 KiB
Go
278 lines
7.5 KiB
Go
//go:build linux
|
|
|
|
package system
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"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 ---
|
|
readMemInfo(&info)
|
|
|
|
// --- Root filesystem disk usage ---
|
|
readDiskUsage("/", &info.DiskTotalGB, &info.DiskUsedGB, &info.DiskAvailGB, &info.DiskPercent)
|
|
|
|
// --- HDD disk usage (if configured) ---
|
|
if hddPath != "" {
|
|
info.HDDConfigured = true
|
|
readDiskUsage(hddPath, &info.HDDTotalGB, &info.HDDUsedGB, &info.HDDAvailGB, &info.HDDPercent)
|
|
}
|
|
|
|
// --- Load average ---
|
|
readLoadAvg(&info)
|
|
|
|
// --- Temperature ---
|
|
readTemperature(&info)
|
|
|
|
// --- CPU from collector ---
|
|
if cpuCollector != nil {
|
|
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
|
|
}
|
|
|
|
// GetTotalMemoryMB reads total system memory from /proc/meminfo.
|
|
func GetTotalMemoryMB() (int, error) {
|
|
info := SystemInfo{}
|
|
readMemInfo(&info)
|
|
if info.TotalMemMB == 0 {
|
|
return 0, fmt.Errorf("could not read MemTotal from /proc/meminfo")
|
|
}
|
|
return int(info.TotalMemMB), nil
|
|
}
|
|
|
|
// GetMemoryMB returns total and used system memory in MB from /proc/meminfo.
|
|
func GetMemoryMB() (totalMB, usedMB int, err error) {
|
|
info := SystemInfo{}
|
|
readMemInfo(&info)
|
|
if info.TotalMemMB == 0 {
|
|
return 0, 0, fmt.Errorf("could not read MemTotal from /proc/meminfo")
|
|
}
|
|
return int(info.TotalMemMB), int(info.UsedMemMB), nil
|
|
}
|
|
|
|
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()
|
|
|
|
var totalKB, availKB uint64
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
switch {
|
|
case strings.HasPrefix(line, "MemTotal:"):
|
|
totalKB = parseMemLine(line)
|
|
case strings.HasPrefix(line, "MemAvailable:"):
|
|
availKB = parseMemLine(line)
|
|
}
|
|
if totalKB > 0 && availKB > 0 {
|
|
break
|
|
}
|
|
}
|
|
|
|
if totalKB > 0 {
|
|
info.TotalMemMB = totalKB / 1024
|
|
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")
|
|
}
|
|
}
|
|
|
|
// parseMemLine extracts the kB value from a /proc/meminfo line like "MemTotal: 16384000 kB"
|
|
func parseMemLine(line string) uint64 {
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) < 2 {
|
|
return 0
|
|
}
|
|
valStr := strings.TrimSpace(parts[1])
|
|
valStr = strings.TrimSuffix(valStr, " kB")
|
|
valStr = strings.TrimSpace(valStr)
|
|
|
|
var val uint64
|
|
for _, c := range valStr {
|
|
if c >= '0' && c <= '9' {
|
|
val = val*10 + uint64(c-'0')
|
|
}
|
|
}
|
|
return val
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
bsize := uint64(stat.Bsize)
|
|
total := stat.Blocks * bsize
|
|
avail := stat.Bavail * bsize
|
|
used := total - (stat.Bfree * bsize)
|
|
|
|
const gb = 1024 * 1024 * 1024
|
|
*totalGB = float64(total) / gb
|
|
*usedGB = float64(used) / gb
|
|
*availGB = float64(avail) / gb
|
|
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.
|
|
// Tries /host/sys first (Docker mount), then /sys (native).
|
|
func readTemperature(info *SystemInfo) {
|
|
prefixes := []string{"/host/sys", "/sys"}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
pattern := filepath.Join(sysPrefix, "class", "thermal", "thermal_zone*", "temp")
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil || len(matches) == 0 {
|
|
return false
|
|
}
|
|
|
|
sort.Strings(matches)
|
|
debugf("[DEBUG] [system] readThermalZones: %s — found %d zones", sysPrefix, len(matches))
|
|
|
|
var maxTemp float64
|
|
var maxSource string
|
|
|
|
for _, tempPath := range matches {
|
|
data, err := os.ReadFile(tempPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var milliDeg int64
|
|
if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &milliDeg); err != nil {
|
|
continue
|
|
}
|
|
|
|
temp := float64(milliDeg) / 1000.0
|
|
|
|
// Read the type file for the label
|
|
zoneDir := filepath.Dir(tempPath)
|
|
typePath := filepath.Join(zoneDir, "type")
|
|
typeData, err := os.ReadFile(typePath)
|
|
source := strings.TrimSpace(string(typeData))
|
|
if err != nil || source == "" {
|
|
source = filepath.Base(zoneDir)
|
|
}
|
|
|
|
if temp > maxTemp {
|
|
maxTemp = temp
|
|
maxSource = source
|
|
}
|
|
}
|
|
|
|
if maxTemp > 0 {
|
|
info.TemperatureCelsius = maxTemp
|
|
info.TemperatureSource = maxSource
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func readHwmon(sysPrefix string, info *SystemInfo) bool {
|
|
pattern := filepath.Join(sysPrefix, "class", "hwmon", "hwmon*", "temp1_input")
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil || len(matches) == 0 {
|
|
return false
|
|
}
|
|
|
|
var maxTemp float64
|
|
var maxSource string
|
|
|
|
for _, tempPath := range matches {
|
|
data, err := os.ReadFile(tempPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var milliDeg int64
|
|
if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &milliDeg); err != nil {
|
|
continue
|
|
}
|
|
|
|
temp := float64(milliDeg) / 1000.0
|
|
source := filepath.Base(filepath.Dir(tempPath))
|
|
|
|
if temp > maxTemp {
|
|
maxTemp = temp
|
|
maxSource = source
|
|
}
|
|
}
|
|
|
|
if maxTemp > 0 {
|
|
info.TemperatureCelsius = maxTemp
|
|
info.TemperatureSource = maxSource
|
|
return true
|
|
}
|
|
return false
|
|
}
|